From 78842af1c30de9c7561f10b4129aba4e1f30db27 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:41:14 +0100 Subject: [PATCH] fix: Compute "virtual" adjustments for order previews (#13306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * add wip * wip * reuse action * finish first draft * fix tests * cleanup * Only compute adjustments when necessary * Create hot-carrots-look.md * address comments * minor tweaks * fix pay col * fix test * wip * Dwip * wip * fix: adjustment typo * fix: import * fix: workflow imports * wip: update test * feat: upsert versioned adjustments when previewing order * fix: revert unique codes change * fix: order spec test with versioning * wip: save * feat: make adjustments work for preview and confirm flow, wip base repo filtering of older version adjustments * fix: missing populate where * wip: populate where loading versioned adjustments * fix: filter out older adjustment versions * temp: comment adjustments in repo * test: add adjustment if no version * wip: configure populate where in order base repository * fix: rm manual filtering * fix: revert base repo changes * fix: revert * fix: use order item version instead of order version * fix: rm only in test * fix: update case spec * fix: remove sceanrio, wip test with draft promotion * feat: test correct adjustments when disabling promotion * feat: complex test case * feat: test consecutive order edits * feat: 2 promotions test case with a fixed promo * feat: migrate existing order line item adjustments to order items latest version * feat: update dep after merge * wip: load adjustments separatley * feat: adjustments collections * fix: spread result, handle related entity case * fix: update lock * feat: make sure version is loaded, refactor, handle related entity case * fix: check fields * feat: loading adjustments for list and count * fix: correct items version field * fix: rm empty array * fix: wip order modules spec * fix: order module specs * feat: preinit items adjustments * fix: rm only * fix: rm only * chore: cleanup * fix: migration files * fix: dont change formatting * fix: core package build * chore: more cleanup * fix: item update util * fix: duplicate import * fix: refresh adjustments for exchanges (#13992) * wip: exchange adjustments * feat: test - receive items * feat: finish test case * fix: casing * fix(draft-orders, core-flows, orders) refresh adjustments for draft orders (#14025) * wip: draft orders adjustments refresh * feat: rewrite to use REPLACE action + test * fix: rm only * feat: cleanup old REPLACE actions * feat: cleanup adjustemnts when 0 promotions * wip: canceling draft order * fix: make version arg optional * fix: restore promotion links * feat: test reverting on cancelation * fix: address comments in tests * wip: fix summary on preview * fix: get pending diff on preview summary from total * fix: revert pending diff change --------- Co-authored-by: fPolic Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com> --- .changeset/hot-carrots-look.md | 10 + .../__tests__/exchanges/exchanges.spec.ts | 302 ++++ .../__tests__/order-edits/order-edits.spec.ts | 1228 +++++++++++++++++ .../__tests__/order/draft-order.spec.ts | 697 ++++++++++ .../modules/__tests__/order/order.spec.ts | 1 + .../get-actions-to-compute-from-promotions.ts | 35 +- .../cart/workflows/update-cart-promotions.ts | 2 +- ...reate-draft-order-line-item-adjustments.ts | 7 +- .../workflows/add-draft-order-items.ts | 16 +- .../workflows/add-draft-order-promotions.ts | 17 +- .../add-draft-order-shipping-methods.ts | 17 +- .../workflows/cancel-draft-order-edit.ts | 72 +- .../compute-draft-order-adjustments.ts | 213 +++ .../src/draft-order/workflows/index.ts | 1 + .../refresh-draft-order-adjustments.ts | 7 +- .../remove-draft-order-action-item.ts | 10 +- ...move-draft-order-action-shipping-method.ts | 10 +- .../remove-draft-order-promotions.ts | 17 +- .../remove-draft-order-shipping-method.ts | 22 +- .../update-draft-order-action-item.ts | 10 +- ...date-draft-order-action-shipping-method.ts | 10 +- .../workflows/update-draft-order-item.ts | 11 +- .../update-draft-order-shipping-method.ts | 14 +- ...eate-or-update-order-payment-collection.ts | 2 +- .../exchange/exchange-add-new-item.ts | 28 +- .../exchange/exchange-request-item-return.ts | 21 +- .../core-flows/src/order/workflows/index.ts | 5 +- .../workflows/order-edit/begin-order-edit.ts | 4 +- .../compute-adjustments-for-preview.ts | 163 +++ .../order-edit/confirm-order-edit-request.ts | 45 +- .../order-edit/order-edit-add-new-item.ts | 24 +- .../order-edit-update-item-quantity.ts | 26 +- .../remove-order-edit-item-action.ts | 13 +- .../order-edit/update-order-edit-add-item.ts | 8 + .../update-order-edit-item-quantity.ts | 11 + .../workflows/order-edit/utils/fields.ts | 9 +- .../src/order/workflows/update-tax-lines.ts | 20 +- packages/core/framework/package.json | 2 +- packages/core/types/src/order/common.ts | 6 + packages/core/types/src/order/mutations.ts | 5 + .../src/promotion/common/compute-actions.ts | 12 +- .../utils/src/order/order-change-action.ts | 1 + packages/core/utils/src/totals/cart/index.ts | 1 - .../core/utils/src/totals/line-item/index.ts | 2 +- .../[id]/edit/promotions/route.ts | 1 - .../__tests__/order-edit.spec.ts | 80 ++ .../migrations/.snapshot-medusa-order.json | 19 + .../src/migrations/Migration20251016160403.ts | 33 + .../order/src/models/line-item-adjustment.ts | 1 + .../src/services/order-module-service.ts | 46 +- .../modules/order/src/types/utils/index.ts | 6 +- .../modules/order/src/utils/actions/index.ts | 2 + .../order/src/utils/actions/item-add.ts | 1 - .../utils/actions/item-adjustments-replace.ts | 32 + .../order/src/utils/actions/item-update.ts | 4 + .../order/src/utils/apply-order-changes.ts | 26 +- .../order/src/utils/base-repository-find.ts | 106 +- .../order/src/utils/calculate-order-change.ts | 2 + .../src/services/promotion-module.ts | 4 +- yarn.lock | 4 +- 60 files changed, 3271 insertions(+), 233 deletions(-) create mode 100644 .changeset/hot-carrots-look.md create mode 100644 packages/core/core-flows/src/draft-order/workflows/compute-draft-order-adjustments.ts create mode 100644 packages/core/core-flows/src/order/workflows/order-edit/compute-adjustments-for-preview.ts create mode 100644 packages/modules/order/src/migrations/Migration20251016160403.ts create mode 100644 packages/modules/order/src/utils/actions/item-adjustments-replace.ts diff --git a/.changeset/hot-carrots-look.md b/.changeset/hot-carrots-look.md new file mode 100644 index 0000000000..3dd0559cb8 --- /dev/null +++ b/.changeset/hot-carrots-look.md @@ -0,0 +1,10 @@ +--- +"@medusajs/order": patch +"@medusajs/promotion": patch +"@medusajs/core-flows": patch +"@medusajs/types": patch +"@medusajs/utils": patch +"integration-tests-http": patch +--- + +fix: Compute "virtual" adjustments for order previews diff --git a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts index b72de66c80..0b1d0f14eb 100644 --- a/integration-tests/http/__tests__/exchanges/exchanges.spec.ts +++ b/integration-tests/http/__tests__/exchanges/exchanges.spec.ts @@ -1,8 +1,11 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { IOrderModuleService, IPromotionModuleService } from "@medusajs/types" import { ContainerRegistrationKeys, Modules, ProductStatus, + PromotionStatus, + PromotionType, RuleOperator, } from "@medusajs/utils" import { @@ -913,6 +916,305 @@ medusaIntegrationTestRunner({ expect(updatedClaimShippingMethods).toHaveLength(0) }) }) + + describe("Exchange adjustments", () => { + let appliedPromotion + let promotionModule: IPromotionModuleService + let orderModule: IOrderModuleService + let remoteLink + let orderWithPromotion + let productForAdjustmentTest + + beforeEach(async () => { + const container = getContainer() + promotionModule = container.resolve(Modules.PROMOTION) + orderModule = container.resolve(Modules.ORDER) + remoteLink = container.resolve(ContainerRegistrationKeys.LINK) + + productForAdjustmentTest = ( + await api.post( + "/admin/products", + { + title: "Product for adjustment test", + status: ProductStatus.PUBLISHED, + shipping_profile_id: shippingProfile.id, + options: [{ title: "size", values: ["large", "small"] }], + variants: [ + { + title: "Test variant", + sku: "test-variant-adjustment", + manage_inventory: false, + options: { size: "large" }, + prices: [ + { + currency_code: "usd", + amount: 12, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + appliedPromotion = await promotionModule.createPromotions({ + code: "PROMOTION_APPLIED", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + application_method: { + type: "percentage", + target_type: "order", + allocation: "each", + value: 10, + max_quantity: 5, + currency_code: "usd", + target_rules: [], + }, + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: ( + await api.get("/admin/sales-channels", adminHeaders) + ).data.sales_channels[0].id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + // @ts-ignore + orderWithPromotion = await orderModule.createOrders({ + email: "foo@bar.com", + region_id: ( + await api.get("/admin/regions", adminHeaders) + ).data.regions[0].id, + sales_channel_id: ( + await api.get("/admin/sales-channels", adminHeaders) + ).data.sales_channels[0].id, + items: [ + { + // @ts-ignore + id: "item-1", + title: "Custom Item", + quantity: 1, + unit_price: 10, + }, + ], + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + currency_code: "usd", + }) + + await orderModule.createOrderLineItemTaxLines(orderWithPromotion.id, [ + { + // @ts-ignore + item_id: "item-1", + code: "standard", + rate: 10, + description: "tax-1", + provider_id: "system", + total: 1.2, + subtotal: 1.2, + }, + ]) + + await orderModule.createOrderLineItemAdjustments([ + { + version: 1, + code: appliedPromotion.code!, + amount: 1, + item_id: "item-1", + promotion_id: appliedPromotion.id, + }, + ]) + + await remoteLink.create({ + [Modules.ORDER]: { order_id: orderWithPromotion.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + }) + + it("should update adjustments when adding an inbound and outbound item", async () => { + // First item -> 10$ | 10% discount tax excl. | 10% tax + // Second item -> 12$ | 10% discount tax excl. | 2% tax + + // 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 + const orderId = result.data.exchange.order_id + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)) + .data.order + + expect(result.original_total).toEqual(11) // $10 + 10% tax + expect(result.total).toEqual(10 * 0.9 * 1.1) // ($10 - 10% discount) + 10% tax + + // Add outbound item with price $12, 10% discount and 10% tax + result = ( + await api + .post( + `/admin/exchanges/${exchangeId}/outbound/items`, + { + items: [ + { + variant_id: productForAdjustmentTest.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + .catch((e) => console.log(e)) + ).data.order_preview + + expect(result.total).toEqual(20.916) // 10 * 0.9 * 1.1 + 12 * 0.9 * 1.02 + expect(result.original_total).toEqual(23.24) // 10 * 1.1 + 12 * 1.02 + + // Confirm that the adjustment values are correct + const adjustments = result.items[0].adjustments + const adjustments2 = result.items[1].adjustments + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 1, + }), + ]) + expect(adjustments2).toEqual([ + expect.objectContaining({ + amount: 1.2, + }), + ]) + + let orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // confirm original order is not updated + expect(orderResult.total).toEqual(9.9) // initial item 10$ and 10% discount and 10% tax + expect(orderResult.original_total).toEqual(11) // initial item 10$ + 10% tax + + const originalItemId = result.items[0].id + + // Request inbound item return + result = ( + await api + .post( + `/admin/exchanges/${exchangeId}/inbound/items`, + { + items: [ + { + id: originalItemId, + reason_id: returnReason.id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + .catch((e) => console.log(e)) + ).data.order_preview + + const returnId = result.order_change.return_id + + await api.post( + `/admin/exchanges/${exchangeId}/request`, + {}, + adminHeaders + ) + + orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // after exchange request order contains both items and adjustments untill return is received + expect(orderResult.total).toEqual(20.916) // 10 * 0.9 * 1.1 + 12 * 0.9 * 1.02 + expect(orderResult.original_total).toEqual(23.24) // 10 * 1.1 + 12 * 1.02 + + await api.post(`/admin/returns/${returnId}/receive`, {}, adminHeaders) + + orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // still the same state while return receive process is pending + expect(orderResult.total).toEqual(20.916) // 10 * 0.9 * 1.1 + 12 * 0.9 * 1.02 + expect(orderResult.original_total).toEqual(23.24) // 10 * 1.1 + 12 * 1.02 + + await api.post( + `/admin/returns/${returnId}/receive-items`, + { + items: [ + { + id: originalItemId, + quantity: 1, + }, + ], + }, + adminHeaders + ) + + orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // still the same state while return receive process is pending + expect(orderResult.total).toEqual(20.916) // 10 * 0.9 * 1.1 + 12 * 0.9 * 1.02 + expect(orderResult.original_total).toEqual(23.24) // 10 * 1.1 + 12 * 1.02 + + await api.post( + `/admin/returns/${returnId}/receive/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // after confirmation only first added item is active + expect(orderResult2.total).toEqual(11.016) + expect(orderResult2.original_total).toEqual(12.24) + }) + }) }) }, }) 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 1c8fc8ee01..c97e36dda6 100644 --- a/integration-tests/http/__tests__/order-edits/order-edits.spec.ts +++ b/integration-tests/http/__tests__/order-edits/order-edits.spec.ts @@ -1,9 +1,12 @@ import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { IOrderModuleService, IPromotionModuleService } from "@medusajs/types" import { ContainerRegistrationKeys, Modules, OrderChangeStatus, ProductStatus, + PromotionStatus, + PromotionType, RuleOperator, } from "@medusajs/utils" import { @@ -31,6 +34,8 @@ medusaIntegrationTestRunner({ let container let region let salesChannel + let buyRuleProduct + const shippingProviderId = "manual_test-provider" beforeEach(async () => { @@ -157,6 +162,32 @@ medusaIntegrationTestRunner({ ) ).data.product + buyRuleProduct = ( + await api.post( + "/admin/products", + { + title: "Buy rule product", + options: [{ title: "size", values: ["large", "small"] }], + shipping_profile_id: shippingProfile.id, + variants: [ + { + title: "buy rule variant", + manage_inventory: false, + sku: "buy-rule-variant-sku", + options: { size: "large" }, + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + const orderModule = container.resolve(Modules.ORDER) order = await orderModule.createOrders({ @@ -1152,5 +1183,1202 @@ medusaIntegrationTestRunner({ ) }) }) + + describe("Order Edits promotions", () => { + let appliedPromotion + let promotionModule: IPromotionModuleService + let orderModule: IOrderModuleService + let remoteLink + + beforeEach(async () => { + promotionModule = container.resolve(Modules.PROMOTION) + remoteLink = container.resolve(ContainerRegistrationKeys.LINK) + + appliedPromotion = await promotionModule.createPromotions({ + code: "PROMOTION_APPLIED", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + application_method: { + type: "percentage", + target_type: "order", + allocation: "across", + value: 10, + currency_code: "usd", + target_rules: [], + }, + }) + + await remoteLink.create([ + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + ]) + + orderModule = container.resolve(Modules.ORDER) + + // @ts-ignore + order = await orderModule.createOrders({ + email: "foo@bar.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + items: [ + { + // @ts-ignore + id: "item-1", + title: "Custom Item", + quantity: 1, + unit_price: 10, + }, + ], + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + currency_code: "usd", + }) + + await orderModule.createOrderLineItemTaxLines(order.id, [ + { + // TODO check why item_id is not in param + // @ts-ignore + item_id: "item-1", + code: "tax-1", + rate: 10, + description: "tax-1", + // @ts-ignore + code: "standard", + provider_id: "system", + total: 1.2, + subtotal: 1.2, + }, + ]) + + await orderModule.createOrderLineItemAdjustments([ + { + version: 1, + code: appliedPromotion.code!, + amount: 1, + item_id: "item-1", + promotion_id: appliedPromotion.id, + }, + ]) + + await remoteLink.create({ + [Modules.ORDER]: { order_id: order.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + }) + + it("should update adjustments when adding a new item", async () => { + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(11) // $10 + 10% tax + expect(result.total).toEqual(10 * 0.9 * 1.1) // ($10 - 10% discount) + 10% tax + + // Add item with price $12, 10% discount and 10% tax + result = ( + await api.post( + `/admin/order-edits/${orderId}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order_preview + + // two items of $12 and $10 and 10% discount -> subtotal is $22 - 10% discount = $19.8 + // Aside from this there is a tax rate of 10%, which adds (19.8 / 10 = $1.98) + // Total is $19.8 + $1.98 = $21.78 + expect(result.total).toEqual(21.78) + expect(result.original_total).toEqual(24.2) // $22 + 10% tax + + // Confirm that the adjustment values are correct + const adjustments = result.items[0].adjustments + const adjustments2 = result.items[1].adjustments + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 1, + }), + ]) + expect(adjustments2).toEqual([ + expect.objectContaining({ + amount: 1.2, + }), + ]) + + const orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // confirm original order is not updated + expect(orderResult.total).toEqual(9.9) // initial item 10$ and 10% discount and 10% tax + expect(orderResult.original_total).toEqual(11) // initial item 10$ + 10% tax + + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + expect(orderResult2.total).toEqual(21.78) + expect(orderResult2.original_total).toEqual(24.2) + }) + + it("should update adjustments when updating an item", async () => { + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + + const item = order.items[0] + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(11) // $10 + 10% tax + expect(result.total).toEqual(9.9) // ($10 - 10% discount) + 10% tax = 9 + 0.9 = 9.9 + + let adjustments = result.items[0].adjustments + + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 1, + item_id: item.id, + version: 1, + }), + ]) + + // Update item quantity + result = ( + await api.post( + `/admin/order-edits/${orderId}/items/item/${item.id}`, + { + quantity: 2, + }, + adminHeaders + ) + ).data.order_preview + + expect(result.original_total).toEqual(22) // $20 + 10% tax + expect(result.total).toEqual(19.8) // ($20 - 10% discount) + 10% tax = 18 + 1.8 = 19.8 + + adjustments = result.items[0].adjustments + + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 2, + item_id: item.id, + // version: 2, + }), + ]) + + const orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // confirm original order is not updated + expect(orderResult.original_total).toEqual(11) + expect(orderResult.total).toEqual(9.9) + + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + expect(orderResult2.original_total).toEqual(22) + expect(orderResult2.total).toEqual(19.8) + }) + + it("should update adjustments when removing an item", async () => { + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + + const item = order.items[0] + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(11) // $10 + 10% tax + expect(result.total).toEqual(9.9) // ($10 - 10% discount) + 10% tax = 9 + 0.9 = 9.9 + + let adjustments = result.items[0].adjustments + + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 1, + item_id: item.id, + }), + ]) + + result = ( + await api.post( + `/admin/order-edits/${orderId}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order_preview + + const orderItems = result.items + + expect(orderItems).toEqual([ + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + amount: 1, + item_id: item.id, + }), + ], + }), + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + amount: 1.2, + }), + ], + }), + ]) + + const newItem = result.items.find( + (item) => item.variant_id === productExtra.variants[0].id + ) + + const actionId = newItem.actions[0].id + + result = ( + await api.delete( + `/admin/order-edits/${orderId}/items/${actionId}`, + adminHeaders + ) + ).data.order_preview + + adjustments = result.items[0].adjustments + + expect(adjustments).toEqual([ + expect.objectContaining({ + amount: 1, + item_id: item.id, + }), + ]) + + const orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // confirm original order is not updated + expect(orderResult.total).toEqual(9.9) + expect(orderResult.original_total).toEqual(11) + + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + expect(orderResult2.total).toEqual(9.9) + expect(orderResult2.original_total).toEqual(11) + }) + + it("should update adjustments correctly when adding items in 2 consecutive order edits", async () => { + // 1. Create the first order edit + let response = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "First order edit", + }, + adminHeaders + ) + const orderChange1 = response.data.order_change + + // 2. Add a new item in the first edit + response = await api.post( + `/admin/order-edits/${orderChange1.order_id}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + let orderEditPreview1 = response.data.order_preview + + const originalItem = orderEditPreview1.items.find( + (i) => i.variant_id === order.items[0].variant_id + ) + const addedItem1 = orderEditPreview1.items.find( + (i) => i.variant_id === productExtra.variants[0].id + ) + expect(addedItem1).toBeDefined() + expect(addedItem1.quantity).toBe(1) + expect(orderEditPreview1.items.length).toBe(2) + + // Validate adjustments for added item in edit 1 + expect(addedItem1.adjustments).toEqual([ + expect.objectContaining({ + item_id: addedItem1.id, + amount: 1.2, + }), + ]) + + // Validate total and original total after first edit + // original: $10 (orig item) + $12 (added) = $22 → 10% tax = $2.2 → 24.2 + // promo: -$1 (orig item), -$1.2 (added) → subtotal $19.8, 10% = 1.98 → 21.78 + expect(orderEditPreview1.original_total).toBeCloseTo(24.2) + expect(orderEditPreview1.total).toBeCloseTo(21.78) + + // 3. Confirm the first order edit + response = await api.post( + `/admin/order-edits/${orderChange1.order_id}/confirm`, + {}, + adminHeaders + ) + expect(response.status).toBe(200) + + // validate order + let orderResult = ( + await api.get(`/admin/orders/${order.id}`, adminHeaders) + ).data.order + expect(orderResult.original_total).toEqual(24.2) + expect(orderResult.total).toEqual(21.78) + + expect(orderResult.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: originalItem.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: originalItem.id, + amount: 1, + version: 2, + }), + ]), + }), + expect.objectContaining({ + id: addedItem1.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: addedItem1.id, + amount: 1.2, + version: 2, + }), + ]), + }), + ]) + ) + + // 4. Create the second order edit + response = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Second order edit", + }, + adminHeaders + ) + const orderChange2 = response.data.order_change + + // 5. Add another productExtra item + response = await api.post( + `/admin/order-edits/${orderChange2.order_id}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 2, + }, + ], + }, + adminHeaders + ) + + let orderEditPreview2 = response.data.order_preview + + // Adjustments should account for all quantity + // The individual adjustment for this add: -$1.2 per quantity added (for second edit), but for a 3-quantity line, adjustment stack + // We must find the two promo adjustments (from first and second edit): -$1.2 (from before, quantity 1), -$2.4 (from just now, quantity 2) + // Check that at least two adjustments exist with matching amount + expect(orderEditPreview2.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: originalItem.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: originalItem.id, + amount: 1, + // version: 3, + }), + ]), + }), + // added in 1st edit + expect.objectContaining({ + id: expect.any(String), + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: expect.any(String), + amount: 1.2, + // version: 3, + }), + ]), + }), + // added in 2nd edit + expect.objectContaining({ + id: expect.any(String), + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: expect.any(String), + amount: 2.4, + // version: 3, + }), + ]), + }), + ]) + ) + // Validate totals after the second edit with explicit numbers + // Original: $10 (orig) + $12*3 (added, 3 qty) = 10+36=46; 46*0.1=4.6 tax -> 50.6 + // Promos: -$1 (orig), -$1.2 (first add), -$2.4 (second add, 2 qty * 1.2) + // subtotal after promos: 46 - 1 - 1.2 - 2.4 = 41.4; 41.4*0.1=4.14 tax -> total: 45.54 + expect(orderEditPreview2.original_total).toBeCloseTo(50.6) + expect(orderEditPreview2.total).toBeCloseTo(45.54) + + // validate that order is in the same state as after the first edit before we confirm the second edit + orderResult = (await api.get(`/admin/orders/${order.id}`, adminHeaders)) + .data.order + expect(orderResult.original_total).toEqual(24.2) + expect(orderResult.total).toEqual(21.78) + + expect(orderResult.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: originalItem.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: originalItem.id, + amount: 1, + version: 2, + }), + ]), + }), + expect.objectContaining({ + id: addedItem1.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: addedItem1.id, + amount: 1.2, + version: 2, + }), + ]), + }), + ]) + ) + + // 6. Confirm the second order edit + response = await api.post( + `/admin/order-edits/${orderChange2.order_id}/confirm`, + {}, + adminHeaders + ) + expect(response.status).toBe(200) + + // 7. Retrieve the final order and validate + response = await api.get(`/admin/orders/${order.id}`, adminHeaders) + const finalOrder = response.data.order + + // Adjustments in DB: two on added item + expect(finalOrder.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: originalItem.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: originalItem.id, + amount: 1, + version: 3, + }), + ]), + }), + expect.objectContaining({ + id: addedItem1.id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: addedItem1.id, + amount: 1.2, + version: 3, + }), + ]), + }), + expect.objectContaining({ + id: expect.any(String), + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: expect.any(String), + amount: 2.4, + version: 3, + }), + ]), + }), + ]) + ) + + // Totals should match previous calculation + expect(finalOrder.original_total).toBeCloseTo(50.6) + expect(finalOrder.total).toBeCloseTo(45.54) + }) + + it("should update adjustments correctly when 2 promotions are applied (one is fixed, one is percentage)", async () => { + const publishableKey = await generatePublishableKey(container) + const storeHeaders = generateStoreHeaders({ publishableKey }) + + const product = ( + await api.post( + "/admin/products", + { + title: "Prod 1", + status: ProductStatus.PUBLISHED, + sales_channels: [{ id: salesChannel.id }], + + options: [{ title: "size", values: ["large", "small"] }], + shipping_profile_id: undefined, + variants: [ + { + title: "Prod 1 variant", + manage_inventory: false, + sku: "prod-1-variant-123", + options: { size: "large" }, + prices: [ + { + currency_code: "usd", + amount: 10, + }, + ], + }, + ], + }, + adminHeaders + ) + ).data.product + + // 1. Create two promotions: one fixed ($2 off), one 10% off + const fixedPromotion = await api.post( + "/admin/promotions", + { + code: "FIXED2", + type: "standard", + status: "active", + application_method: { + type: "fixed", + currency_code: "usd", + target_type: "items", + allocation: "each", + max_quantity: 5, + value: 2, + }, + is_automatic: false, + }, + adminHeaders + ) + + const percentPromotion = await api.post( + "/admin/promotions", + { + code: "PERCENT10", + type: "standard", + status: "active", + application_method: { + type: "percentage", + currency_code: "usd", + target_type: "items", + allocation: "across", + value: 10, + }, + is_automatic: false, + }, + adminHeaders + ) + + // 2. Create a fresh cart with a single item + const freshCartRes = await api.post( + "/store/carts", + { + sales_channel_id: salesChannel.id, + currency_code: "usd", + region_id: order.region_id, + email: "multi-promo@example.com", + items: [ + { + variant_id: product.variants[0].id, + quantity: 1, + }, + ], + // Apply both promotions to the cart + promo_codes: ["FIXED2", "PERCENT10"], + }, + storeHeaders + ) + + const freshCartId = freshCartRes.data.cart.id + + const paymentCollection = ( + await api.post( + `/store/payment-collections`, + { cart_id: freshCartId }, + storeHeaders + ) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { + provider_id: "pp_system_default", + }, + storeHeaders + ) + + const completeOrderRes = await api.post( + `/store/carts/${freshCartId}/complete`, + {}, + storeHeaders + ) + + const newOrderId = completeOrderRes.data.order.id + let response = await api.get( + `/admin/orders/${newOrderId}`, + adminHeaders + ) + let freshOrder = response.data.order + const originalItem = freshOrder.items[0] + + // Check adjustments on item: should have both promos + expect(originalItem.adjustments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + amount: 2, // fixed accross + code: "FIXED2", + }), + expect.objectContaining({ + amount: 1, // 10% off + code: "PERCENT10", + }), + ]) + ) + + expect(freshOrder.original_total).toBe(10) // just 10$ item without tax + expect(freshOrder.total).toBe(7) // 10$ - 2$ fixed - 10% = 7$ + + const editRes = await api.post( + "/admin/order-edits", + { + order_id: newOrderId, + }, + adminHeaders + ) + const editOrderId = editRes.data.order_change.order_id + const extraVariantId = productExtra.variants[0].id + + const addedItemsResult = ( + await api.post( + `/admin/order-edits/${editOrderId}/items`, + { + items: [ + { + variant_id: extraVariantId, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order_preview + + const addedItem = addedItemsResult.items.find( + (i) => i.variant_id === extraVariantId + ) + + const initialItem = addedItemsResult.items.find( + (i) => i.variant_id === product.variants[0].id + ) + expect(addedItem).toBeDefined() + + // Should have both promo adjustments on new item as well + expect(addedItem.adjustments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "FIXED2", + amount: 2, + version: 2, + }), + expect.objectContaining({ + code: "PERCENT10", + amount: 1.2, + version: 2, + }), + ]) + ) + + expect(addedItemsResult.original_total).toBe(10 + 12) + expect(addedItemsResult.total).toBe(15.8) // 22$ - 2$ - 2$ - 1.2$ - 1$ + + expect(initialItem.adjustments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "FIXED2", + amount: 2, + }), + expect.objectContaining({ + code: "PERCENT10", + amount: 1, + }), + ]) + ) + + const initialOrder = ( + await api.get(`/admin/orders/${newOrderId}`, adminHeaders) + ).data.order + + // original order is still unchanged + expect(initialOrder.original_total).toBe(10) + expect(initialOrder.total).toBe(7) + + await api.post( + `/admin/order-edits/${editOrderId}/confirm`, + {}, + adminHeaders + ) + + const confirmedOrder = ( + await api.get(`/admin/orders/${newOrderId}`, adminHeaders) + ).data.order + + expect(confirmedOrder.original_total).toBe(10 + 12) + expect(confirmedOrder.total).toBe(15.8) // 22$ - 2$ - 2$ - 1.2$ - 1$ + }) + + it("should update adjustments when adding then updating then removing the original item", async () => { + // Create a new order edit + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + const originalItem = order.items[0] + + // Add a new item + result = ( + await api.post( + `/admin/order-edits/${orderId}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order_preview + + const addedItem = result.items.find( + (i) => i.variant_id === productExtra.variants[0].id + ) + expect(addedItem).toBeDefined() // item is added + expect(result.original_total).toEqual(24.2) // ($10 + $12) + 10% tax = 22 + 2.2 = 24.2 + expect(result.total).toEqual(21.78) // ($10 + $12 - $1 - $1.2) + 10% tax = 10 + 12 - 2.2 = 19.8 + 1.98 = 21.78 + + let adjustments = addedItem.adjustments + expect(adjustments).toEqual([ + expect.objectContaining({ + item_id: addedItem.id, + amount: 1.2, + }), + ]) + + // Update the quantity of the newly added item by updating the action + const actionId = addedItem.actions.find( + (a) => a.action === "ITEM_ADD" + )?.id + + result = ( + await api.post( + `/admin/order-edits/${orderId}/items/${actionId}`, + { + quantity: 2, + }, + adminHeaders + ) + ).data.order_preview + + let updatedAddedItem = result.items.find((i) => i.id === addedItem.id) + + expect(result.original_total).toEqual(37.4) // $10 + $12*2 = 10+24=34 + 10% tax = 3.4 = 37.4 + expect(result.total).toEqual(33.66) // ($10 + $24 - $1 - $2.4) + 10% tax = 31.6 + 3.16 = 34.76 + + adjustments = updatedAddedItem.adjustments + expect(adjustments).toEqual([ + expect.objectContaining({ + item_id: updatedAddedItem.id, + amount: 2.4, + }), + ]) + + // Remove the original item by setting its quantity to 0 + result = ( + await api.post( + `/admin/order-edits/${orderId}/items/item/${originalItem.id}`, + { + quantity: 0, + }, + adminHeaders + ) + ).data.order_preview + + // $12*2 + 10% tax = $24 + $2.4 = $26.4 + expect(result.original_total).toEqual(26.4) + expect(result.total).toEqual(23.76) // ($24 - $2.4) + $2.16 = $21.6 + $2.16 = $23.76 + + updatedAddedItem = result.items.find((i) => i.id === addedItem.id) + adjustments = updatedAddedItem.adjustments + expect(adjustments).toEqual([ + expect.objectContaining({ + item_id: updatedAddedItem.id, + amount: 2.4, + }), + ]) + + // Confirm that the original order is not updated + const orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + expect(orderResult.original_total).toEqual(11) + expect(orderResult.total).toEqual(9.9) + + // Confirm the order edit + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + // Final check after confirmation + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + expect(orderResult2.original_total).toEqual(26.4) + expect(orderResult2.total).toEqual(23.76) + }) + + it("should not create adjustments when adding a new item if promotion is disabled", async () => { + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(11) // $10 + 10% tax + expect(result.total).toEqual(9.9) // initial item 10$ and 10% discount and 10% tax + + // promotion was active before so the item has an adjustment + // only now we disable it + + await api.post( + `/admin/promotions/${appliedPromotion.id}`, + { + status: "draft", + }, + adminHeaders + ) + + // Add item with price $12 + $1.2 in taxes + result = ( + await api.post( + `/admin/order-edits/${orderId}/items`, + { + items: [ + { + variant_id: productExtra.variants[0].id, + quantity: 1, + }, + ], + }, + adminHeaders + ) + ).data.order_preview + + expect(result.total).toEqual(13.2 + 11) // since action is replace -> old adjustments are not recreated in this version + expect(result.original_total).toEqual(13.2 + 11) + + // Confirm that the adjustment values are correct + const adjustments = result.items[0].adjustments + const adjustments2 = result.items[1].adjustments + expect(adjustments).toEqual([]) + expect(adjustments2).toEqual([]) + + const orderResult = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // confirm original order is not updated + expect(orderResult.total).toEqual(9.9) // but adjustments of the intial version are still there despite the promotion being disabled in the meantime + expect(orderResult.original_total).toEqual(11) + + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + // The promotion is disabled, so what happens is that promotions that were initially applied are removed + expect(orderResult2.total).toEqual(24.2) + expect(orderResult2.original_total).toEqual(24.2) + }) + + it("should add, remove, and add buy-get adjustment depending on the quantity of the buy rule product", async () => { + promotionModule = container.resolve(Modules.PROMOTION) + + appliedPromotion = await promotionModule.createPromotions({ + code: "BUY_GET_PROMO", + type: "buyget", + status: "active", + application_method: { + allocation: "each", + value: 100, + max_quantity: 1, + type: "percentage", + target_type: "items", + apply_to_quantity: 1, + buy_rules_min_quantity: 2, + target_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [productExtra.id], + }, + ], + buy_rules: [ + { + operator: "eq", + attribute: "items.product.id", + values: [buyRuleProduct.id], + }, + ], + }, + is_tax_inclusive: false, + is_automatic: true, + }) + + const orderModule: IOrderModuleService = container.resolve( + Modules.ORDER + ) + + order = await orderModule.createOrders({ + email: "foo@bar.com", + region_id: region.id, + sales_channel_id: salesChannel.id, + items: [ + { + variant_id: buyRuleProduct.variants[0].id, + quantity: 2, + title: "Buy rule product", + unit_price: 10, + product_id: buyRuleProduct.id, + }, + { + variant_id: productExtra.variants[0].id, + quantity: 1, + title: "Extra product", + unit_price: 10, + product_id: productExtra.id, + adjustments: [ + { + code: appliedPromotion.code!, + amount: 10, + promotion_id: appliedPromotion.id, + }, + ], + }, + ], + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + currency_code: "usd", + }) + + await orderModule.createOrderLineItemAdjustments([ + { + code: appliedPromotion.code!, + amount: 1, + item_id: "item-1", + promotion_id: appliedPromotion.id, + }, + ]) + + const remoteLink = container.resolve(ContainerRegistrationKeys.LINK) + + await remoteLink.create({ + [Modules.ORDER]: { order_id: order.id }, + [Modules.PROMOTION]: { promotion_id: appliedPromotion.id }, + }) + + // Initially, the buy-get adjustment should be added to the order + let result = await api.post( + "/admin/order-edits", + { + order_id: order.id, + description: "Test", + }, + adminHeaders + ) + + const orderId = result.data.order_change.order_id + + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(30) + expect(result.total).toEqual(20) + + const buyRuleItem = result.items.find( + (item) => item.product_id === buyRuleProduct.id + ) + + // Update buy rule product quantity to 1 + // This should remove the buy-get adjustment, as it is no longer valid + result = ( + await api.post( + `/admin/order-edits/${orderId}/items/item/${buyRuleItem.id}`, + { + quantity: 1, + }, + adminHeaders + ) + ).data.order_preview + + expect(result.total).toEqual(20) + expect(result.original_total).toEqual(20) + + // Confirm that the original order is not updated + result = (await api.get(`/admin/orders/${orderId}`, adminHeaders)).data + .order + + expect(result.original_total).toEqual(30) + expect(result.total).toEqual(20) + + await api.post( + `/admin/order-edits/${orderId}/confirm`, + {}, + adminHeaders + ) + + const orderResult2 = ( + await api.get(`/admin/orders/${orderId}`, adminHeaders) + ).data.order + + expect(orderResult2.total).toEqual(20) + expect(orderResult2.original_total).toEqual(20) + }) + }) }, }) diff --git a/integration-tests/modules/__tests__/order/draft-order.spec.ts b/integration-tests/modules/__tests__/order/draft-order.spec.ts index c5807cde77..ecb88d19e3 100644 --- a/integration-tests/modules/__tests__/order/draft-order.spec.ts +++ b/integration-tests/modules/__tests__/order/draft-order.spec.ts @@ -20,6 +20,7 @@ import { createAdminUser, } from "../../../helpers/create-admin-user" import { setupTaxStructure } from "../fixtures" +import { removeDraftOrderPromotionsWorkflow } from "../../../../packages/core/core-flows/dist/draft-order/workflows/remove-draft-order-promotions" jest.setTimeout(100000) @@ -770,6 +771,702 @@ medusaIntegrationTestRunner({ expect(response.status).toEqual(200) }) + it("should apply promotion to a draft order via order edit", async () => { + const region = await regionModuleService.createRegions({ + name: "US", + currency_code: "usd", + }) + + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product, product_2] = await productModule.createProducts([ + { + title: "Test product", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "Test variant", + }, + ], + }, + { + title: "Another product", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "Variant variable", + manage_inventory: false, + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + reserved_quantity: 0, + }, + ]) + + const [priceSet, priceSet_2] = await pricingModule.createPriceSets([ + { + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + { + prices: [ + { + amount: 1000, + currency_code: "usd", + }, + ], + }, + ]) + + /** + * Create a promotion to test with + */ + const promotion = ( + await api.post( + `/admin/promotions`, + { + code: "testytest", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: true, + application_method: { + target_type: "items", + type: "percentage", + allocation: "each", + currency_code: "usd", + value: 10, + max_quantity: 10, + }, + }, + adminHeaders + ) + ).data.promotion + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }, + adminHeaders + ) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet_2.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + await setupTaxStructure(taxModule) + + const payload = { + email: "oli@test.dk", + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + items: [ + { + variant_id: product.variants[0].id, + is_discountable: true, + quantity: 2, // 2 * 3000$ = 6000$ TAX INCLUSIVE + }, + { + variant_id: product_2.variants[0].id, + is_discountable: true, + quantity: 1, // 1 * 1000$ = 1000$ TAX INCLUSIVE + }, + ], + shipping_methods: [ + { + name: "test-method", + shipping_option_id: "test-option", + amount: 0, + }, + ], + } + + const response = await api.post( + "/admin/draft-orders", + payload, + adminHeaders + ) + + // begin Order Edit on a Draft Order + let orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit`, + {}, + adminHeaders + ) + ).data.draft_order_preview + + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit/promotions`, + { + promo_codes: [promotion.code], + }, + adminHeaders + ) + ).data.draft_order_preview + + expect(orderPreview.total).toEqual(6300) // 2 * 3000$ - 10% + 1 * 1000$ - 10% + expect(orderPreview.original_total).toEqual(7000) + expect(orderPreview.discount_total).toEqual(700) + + expect(orderPreview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + total: 5400, // 2 * 3000$ - 10% = 5400$ + original_total: 6000, + discount_total: 600, + variant_id: product.variants[0].id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + code: "testytest", + amount: 600, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + ]), + }), + expect.objectContaining({ + total: 900, // 1000$ - 10% = 900$ + original_total: 1000, + discount_total: 100, + variant_id: product_2.variants[0].id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + code: "testytest", + amount: 100, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + ]), + }), + ]) + ) + + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit/confirm`, + {}, + adminHeaders + ) + ).data.draft_order_preview + + const draftOrder = ( + await api.get( + `/admin/draft-orders/${response.data.draft_order.id}?fields=+total,+original_total,+discount_total,+version,items.*`, + adminHeaders + ) + ).data.draft_order + + expect(draftOrder.total).toEqual(6300) // 2 * 3000$ - 10% + 1 * 1000$ - 10% + expect(draftOrder.original_total).toEqual(7000) + expect(draftOrder.discount_total).toEqual(700) + expect(draftOrder.version).toEqual(2) + + expect(draftOrder.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + total: 5400, + original_total: 6000, + discount_total: 600, + variant_id: product.variants[0].id, + adjustments: [ + expect.objectContaining({ + code: "testytest", + amount: 600, + promotion_id: promotion.id, + is_tax_inclusive: true, + version: 2, + }), + ], + }), + expect.objectContaining({ + total: 900, + original_total: 1000, + discount_total: 100, + variant_id: product_2.variants[0].id, + adjustments: [ + expect.objectContaining({ + code: "testytest", + amount: 100, + promotion_id: promotion.id, + is_tax_inclusive: true, + version: 2, + }), + ], + }), + ]) + ) + }) + + it("should restore promotions and adjustments on a draft order after edit is canceled", async () => { + const region = await regionModuleService.createRegions({ + name: "US", + currency_code: "usd", + }) + + const salesChannel = await scModuleService.createSalesChannels({ + name: "Webshop", + }) + + const location = await stockLocationModule.createStockLocations({ + name: "Warehouse", + }) + + const [product, product_2] = await productModule.createProducts([ + { + title: "Test product", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "Test variant", + }, + ], + }, + { + title: "Another product", + status: ProductStatus.PUBLISHED, + variants: [ + { + title: "Variant variable", + manage_inventory: false, + }, + ], + }, + ]) + + const inventoryItem = await inventoryModule.createInventoryItems({ + sku: "inv-1234", + }) + + await inventoryModule.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: location.id, + stocked_quantity: 2, + reserved_quantity: 0, + }, + ]) + + const [priceSet, priceSet_2] = await pricingModule.createPriceSets([ + { + prices: [ + { + amount: 3000, + currency_code: "usd", + }, + ], + }, + { + prices: [ + { + amount: 1000, + currency_code: "usd", + }, + ], + }, + ]) + + /** + * Create a promotion to test with + */ + const promotion = ( + await api.post( + `/admin/promotions`, + { + code: "testytest", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: true, + application_method: { + target_type: "items", + type: "percentage", + allocation: "each", + currency_code: "usd", + value: 10, + max_quantity: 10, + }, + }, + adminHeaders + ) + ).data.promotion + + // additional promotion that will be added then removed + const promotionAdditional20 = ( + await api.post( + `/admin/promotions`, + { + code: "additional_20", + type: PromotionType.STANDARD, + status: PromotionStatus.ACTIVE, + is_tax_inclusive: true, + application_method: { + target_type: "items", + type: "percentage", + allocation: "each", + currency_code: "usd", + value: 20, + max_quantity: 10, + }, + }, + adminHeaders + ) + ).data.promotion + + await api.post( + "/admin/price-preferences", + { + attribute: "currency_code", + value: "usd", + is_tax_inclusive: true, + }, + adminHeaders + ) + + await remoteLink.create([ + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.PRICING]: { + price_set_id: priceSet_2.id, + }, + }, + { + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + { + [Modules.PRODUCT]: { + variant_id: product_2.variants[0].id, + }, + [Modules.INVENTORY]: { + inventory_item_id: inventoryItem.id, + }, + }, + ]) + + await setupTaxStructure(taxModule) + + const payload = { + email: "oli@test.dk", + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + shipping_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + phone: "12345", + }, + billing_address: { + first_name: "Test", + last_name: "Test", + address_1: "Test", + city: "Test", + country_code: "US", + postal_code: "12345", + }, + items: [ + { + variant_id: product.variants[0].id, + is_discountable: true, + quantity: 2, // 2 * 3000$ = 6000$ TAX INCLUSIVE + }, + { + variant_id: product_2.variants[0].id, + is_discountable: true, + quantity: 1, // 1 * 1000$ = 1000$ TAX INCLUSIVE + }, + ], + shipping_methods: [ + { + name: "test-method", + shipping_option_id: "test-option", + amount: 0, + }, + ], + } + + const response = await api.post( + "/admin/draft-orders", + payload, + adminHeaders + ) + + // begin Order Edit on a Draft Order + let orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit`, + {}, + adminHeaders + ) + ).data.draft_order_preview + + // add the promotion + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit/promotions`, + { + promo_codes: [promotion.code], + }, + adminHeaders + ) + ).data.draft_order_preview + + // confirm changes which applies adjustemnts to the draft order + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit/confirm`, + {}, + adminHeaders + ) + ).data.draft_order_preview + + const draftOrder = ( + await api.get( + `/admin/draft-orders/${response.data.draft_order.id}?fields=+total,+original_total,+discount_total,+version,items.*`, + adminHeaders + ) + ).data.draft_order + + expect(draftOrder.total).toEqual(6300) // 2 * 3000$ - 10% + 1 * 1000$ - 10% + expect(draftOrder.original_total).toEqual(7000) + expect(draftOrder.discount_total).toEqual(700) + expect(draftOrder.version).toEqual(2) + + expect(draftOrder.items[0].adjustments[0].version).toEqual(2) + expect(draftOrder.items[1].adjustments[0].version).toEqual(2) + + expect(orderPreview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + total: 5400, + original_total: 6000, + discount_total: 600, + variant_id: product.variants[0].id, + adjustments: [ + expect.objectContaining({ + code: "testytest", + amount: 600, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + ], + }), + expect.objectContaining({ + total: 900, + original_total: 1000, + discount_total: 100, + variant_id: product_2.variants[0].id, + adjustments: [ + expect.objectContaining({ + code: "testytest", + amount: 100, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + ], + }), + ]) + ) + + // begin another edit... + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit`, + {}, + adminHeaders + ) + ).data.draft_order_preview + + // ...and add the additional promotion + orderPreview = ( + await api.post( + `/admin/draft-orders/${response.data.draft_order.id}/edit/promotions`, + { + promo_codes: [promotionAdditional20.code], + }, + adminHeaders + ) + ).data.draft_order_preview + + expect(orderPreview.total).toEqual(5040) // (2 * 3000$ - 10%) - 20% + (1 * 1000$ - 10%) - 20% + expect(orderPreview.original_total).toEqual(7000) + expect(orderPreview.discount_total).toEqual(600 + 100 + 1080 + 180) + + expect(orderPreview.version).toEqual(2) + expect(orderPreview.items[0].adjustments.length).toEqual(2) + expect(orderPreview.items[1].adjustments.length).toEqual(2) + + // new adjustments exists on the preview + expect(orderPreview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + total: 4320, + original_total: 6000, + discount_total: 1680, + variant_id: product.variants[0].id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + code: "testytest", + amount: 480, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + expect.objectContaining({ + code: "additional_20", + amount: 1200, + promotion_id: promotionAdditional20.id, + is_tax_inclusive: true, + }), + ]), + }), + expect.objectContaining({ + total: 720, + original_total: 1000, + discount_total: 280, + variant_id: product_2.variants[0].id, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + code: "testytest", + amount: 80, + promotion_id: promotion.id, + is_tax_inclusive: true, + }), + expect.objectContaining({ + code: "additional_20", + amount: 200, + promotion_id: promotionAdditional20.id, + is_tax_inclusive: true, + }), + ]), + }), + ]) + ) + + await api.delete( + `/admin/draft-orders/${response.data.draft_order.id}/edit`, + adminHeaders + ) + + const draftOrderAfterCancel = ( + await api.get( + `/admin/draft-orders/${response.data.draft_order.id}?fields=+total,+original_total,+discount_total`, + adminHeaders + ) + ).data.draft_order + + // reverted to be same as before second edit started (10% discount only applied) + expect(draftOrderAfterCancel.total).toEqual(6300) + expect(draftOrderAfterCancel.original_total).toEqual(7000) + expect(draftOrderAfterCancel.discount_total).toEqual(700) + expect(draftOrderAfterCancel.version).toEqual(2) + }) + it("should create a draft order and apply tax by product type", async () => { const productType = await productModule.createProductTypes({ value: "test_product_type", diff --git a/integration-tests/modules/__tests__/order/order.spec.ts b/integration-tests/modules/__tests__/order/order.spec.ts index 02ae4191bb..f56bc4b6cb 100644 --- a/integration-tests/modules/__tests__/order/order.spec.ts +++ b/integration-tests/modules/__tests__/order/order.spec.ts @@ -372,6 +372,7 @@ medusaIntegrationTestRunner({ value: "5e-18", precision: 20, }, + version: 1, provider_id: expect.any(String), created_at: expect.any(String), updated_at: expect.any(String), diff --git a/packages/core/core-flows/src/cart/steps/get-actions-to-compute-from-promotions.ts b/packages/core/core-flows/src/cart/steps/get-actions-to-compute-from-promotions.ts index 1ef5c69c52..ff9be93bc3 100644 --- a/packages/core/core-flows/src/cart/steps/get-actions-to-compute-from-promotions.ts +++ b/packages/core/core-flows/src/cart/steps/get-actions-to-compute-from-promotions.ts @@ -1,56 +1,63 @@ -import type { - CartDTO, - IPromotionModuleService, +import { + ComputeActionContext, + ComputeActionOptions, + IPromotionModuleService } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" /** - * The details of the cart and its applied promotions. + * The details of the items and shipping methods and its applied promotions. */ export interface GetActionsToComputeFromPromotionsStepInput { /** - * The cart to compute the actions for. + * The items and shipping methods to compute the actions for. */ - cart: CartDTO + computeActionContext: ComputeActionContext /** - * The promotion codes applied on the cart. + * The promotion codes applied on the items and shipping methods. */ promotionCodesToApply: string[] + /** + * The options to configure how the actions are computed. + */ + options?: ComputeActionOptions } export const getActionsToComputeFromPromotionsStepId = "get-actions-to-compute-from-promotions" /** * This step retrieves the actions to compute based on the promotions - * applied on a cart. + * applied on items and shipping methods. * * :::tip * - * You can use the {@link retrieveCartStep} to retrieve a cart's details. + * You can use the {@link retrieveCartStep} to retrieve items and shipping methods' details. * * ::: * * @example * const data = getActionsToComputeFromPromotionsStep({ - * // retrieve the details of the cart from another workflow + * // retrieve the details of the items and shipping methods from another workflow * // or in another step using the Cart Module's service - * cart, + * computeActionContext, * promotionCodesToApply: ["10OFF"] * }) */ export const getActionsToComputeFromPromotionsStep = createStep( getActionsToComputeFromPromotionsStepId, async (data: GetActionsToComputeFromPromotionsStepInput, { container }) => { - const { cart, promotionCodesToApply = [] } = data + const { computeActionContext, promotionCodesToApply = [], options } = data + const promotionService = container.resolve( Modules.PROMOTION ) - + const actionsToCompute = await promotionService.computeActions( promotionCodesToApply, - cart as any + computeActionContext, + options ) return new StepResponse(actionsToCompute) diff --git a/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts b/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts index b16bb99649..bb79831ec0 100644 --- a/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts +++ b/packages/core/core-flows/src/cart/workflows/update-cart-promotions.ts @@ -125,7 +125,7 @@ export const updateCartPromotionsWorkflow = createWorkflow( }) const actions = getActionsToComputeFromPromotionsStep({ - cart, + computeActionContext: cart, promotionCodesToApply, }) diff --git a/packages/core/core-flows/src/draft-order/steps/create-draft-order-line-item-adjustments.ts b/packages/core/core-flows/src/draft-order/steps/create-draft-order-line-item-adjustments.ts index 7efcf0757f..7bc7b527b1 100644 --- a/packages/core/core-flows/src/draft-order/steps/create-draft-order-line-item-adjustments.ts +++ b/packages/core/core-flows/src/draft-order/steps/create-draft-order-line-item-adjustments.ts @@ -20,6 +20,10 @@ export interface CreateDraftOrderLineItemAdjustmentsStepInput { * The line item adjustments to create. */ lineItemAdjustmentsToCreate: CreateLineItemAdjustmentDTO[] + /** + * The version of the order change to create the line item adjustments for. + */ + version?: number } /** @@ -43,7 +47,7 @@ export const createDraftOrderLineItemAdjustmentsStep = createStep( data: CreateDraftOrderLineItemAdjustmentsStepInput, { container } ) { - const { lineItemAdjustmentsToCreate = [], order_id } = data + const { lineItemAdjustmentsToCreate = [], order_id, version } = data if (!lineItemAdjustmentsToCreate?.length) { return new StepResponse(void 0, []) @@ -65,6 +69,7 @@ export const createDraftOrderLineItemAdjustmentsStep = createStep( const lineItemAdjustments = await service.createOrderLineItemAdjustments( filteredAdjustments.map((adjustment) => ({ ...adjustment, + version, order_id, })) ) diff --git a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-items.ts b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-items.ts index aaa9e777a6..de26c4637e 100644 --- a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-items.ts +++ b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-items.ts @@ -1,8 +1,4 @@ -import { - ChangeActionType, - OrderChangeStatus, - PromotionActions, -} from "@medusajs/framework/utils" +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -24,8 +20,8 @@ import { } from "../../order" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const addDraftOrderItemsWorkflowId = "add-draft-order-items" @@ -74,7 +70,7 @@ export const addDraftOrderItemsWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: input.order_id, @@ -114,11 +110,9 @@ export const addDraftOrderItemsWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-promotions.ts b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-promotions.ts index 0b64ceec77..b907c4582b 100644 --- a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-promotions.ts +++ b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-promotions.ts @@ -21,8 +21,9 @@ import { } from "../../order" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validatePromoCodesToAddStep } from "../steps/validate-promo-codes-to-add" +import { updateDraftOrderPromotionsStep } from "../steps/update-draft-order-promotions" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" export const addDraftOrderPromotionWorkflowId = "add-draft-order-promotion" @@ -82,7 +83,7 @@ export const addDraftOrderPromotionWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: input.order_id, @@ -110,11 +111,15 @@ export const addDraftOrderPromotionWorkflow = createWorkflow( promotions, }) - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + updateDraftOrderPromotionsStep({ + id: input.order_id, + promo_codes: input.promo_codes, + action: PromotionActions.ADD, + }) + + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order, - promo_codes: input.promo_codes, - action: PromotionActions.ADD, + order_id: input.order_id, }, }) diff --git a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-shipping-methods.ts b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-shipping-methods.ts index 3963a84243..68b77a1fa3 100644 --- a/packages/core/core-flows/src/draft-order/workflows/add-draft-order-shipping-methods.ts +++ b/packages/core/core-flows/src/draft-order/workflows/add-draft-order-shipping-methods.ts @@ -3,7 +3,6 @@ import { isDefined, MedusaError, OrderChangeStatus, - PromotionActions, ShippingOptionPriceType, } from "@medusajs/framework/utils" import { @@ -30,8 +29,8 @@ import { createOrderShippingMethods } from "../../order/steps/create-order-shipp import { prepareShippingMethod } from "../../order/utils/prepare-shipping-method" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" const validateShippingOptionStep = createStep( "validate-shipping-option", @@ -187,19 +186,9 @@ export const addDraftOrderShippingMethodsWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - const refetchedOrder = useRemoteQueryStep({ - entry_point: "orders", - fields: draftOrderFieldsForRefreshSteps, - variables: { id: input.order_id }, - list: false, - throw_if_key_not_found: true, - }).config({ name: "refetched-order-query" }) - - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: refetchedOrder, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/cancel-draft-order-edit.ts b/packages/core/core-flows/src/draft-order/workflows/cancel-draft-order-edit.ts index bcae9f32d5..24dc95b906 100644 --- a/packages/core/core-flows/src/draft-order/workflows/cancel-draft-order-edit.ts +++ b/packages/core/core-flows/src/draft-order/workflows/cancel-draft-order-edit.ts @@ -16,8 +16,8 @@ import { deleteOrderChangesStep, deleteOrderShippingMethods } from "../../order" import { restoreDraftOrderShippingMethodsStep } from "../steps/restore-draft-order-shipping-methods" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { updateDraftOrderPromotionsStep } from "../steps/update-draft-order-promotions" export const cancelDraftOrderEditWorkflowId = "cancel-draft-order-edit" @@ -85,34 +85,6 @@ export const cancelDraftOrderEditWorkflow = createWorkflow( validateDraftOrderChangeStep({ order, orderChange }) - const shippingToRemove = transform( - { orderChange, input }, - ({ orderChange }) => { - return (orderChange.actions ?? []) - .filter((a) => a.action === ChangeActionType.SHIPPING_ADD) - .map(({ reference_id }) => reference_id) - } - ) - - const shippingToRestore = transform( - { orderChange, input }, - ({ orderChange }) => { - return (orderChange.actions ?? []) - .filter((a) => a.action === ChangeActionType.SHIPPING_UPDATE) - .map(({ reference_id, details }) => ({ - id: reference_id, - before: { - shipping_option_id: details?.old_shipping_option_id, - amount: details?.old_amount, - }, - after: { - shipping_option_id: details?.new_shipping_option_id, - amount: details?.new_amount, - }, - })) - } - ) - const promotionsToRemove = transform( { orderChange, input }, ({ orderChange }) => { @@ -155,19 +127,45 @@ export const cancelDraftOrderEditWorkflow = createWorkflow( } ) + updateDraftOrderPromotionsStep({ + id: input.order_id, + promo_codes: promotionsToRefresh as string[], + action: PromotionActions.REPLACE, + }) + + const shippingToRemove = transform( + { orderChange, input }, + ({ orderChange }) => { + return (orderChange.actions ?? []) + .filter((a) => a.action === ChangeActionType.SHIPPING_ADD) + .map(({ reference_id }) => reference_id) + } + ) + + const shippingToRestore = transform( + { orderChange, input }, + ({ orderChange }) => { + return (orderChange.actions ?? []) + .filter((a) => a.action === ChangeActionType.SHIPPING_UPDATE) + .map(({ reference_id, details }) => ({ + id: reference_id, + before: { + shipping_option_id: details?.old_shipping_option_id, + amount: details?.old_amount, + }, + after: { + shipping_option_id: details?.new_shipping_option_id, + amount: details?.new_amount, + }, + })) + } + ) + parallelize( deleteOrderChangesStep({ ids: [orderChange.id] }), deleteOrderShippingMethods({ ids: shippingToRemove }) ) - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ - input: { - order, - promo_codes: promotionsToRefresh, - action: PromotionActions.REPLACE, - }, - }) - when(shippingToRestore, (methods) => !!methods?.length).then(() => { restoreDraftOrderShippingMethodsStep({ shippingMethods: shippingToRestore as any, diff --git a/packages/core/core-flows/src/draft-order/workflows/compute-draft-order-adjustments.ts b/packages/core/core-flows/src/draft-order/workflows/compute-draft-order-adjustments.ts new file mode 100644 index 0000000000..bdb491cfc3 --- /dev/null +++ b/packages/core/core-flows/src/draft-order/workflows/compute-draft-order-adjustments.ts @@ -0,0 +1,213 @@ +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" +import { + createWorkflow, + transform, + when, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import type { + ComputeActionContext, + OrderChangeDTO, + OrderDTO, + PromotionDTO, +} from "@medusajs/framework/types" +import { + getActionsToComputeFromPromotionsStep, + prepareAdjustmentsFromPromotionActionsStep, +} from "../../cart" +import { createOrderChangeActionsWorkflow } from "../../order/workflows/create-order-change-actions" +import { previewOrderChangeStep } from "../../order/steps/preview-order-change" +import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" +import { draftOrderFieldsForRefreshSteps } from "../utils/fields" +import { useRemoteQueryStep } from "../../common" +import { acquireLockStep, releaseLockStep } from "../../locking" +import { deleteOrderChangeActionsStep } from "../../order/steps/delete-order-change-actions" + +export const computeDraftOrderAdjustmentsWorkflowId = + "compute-draft-order-adjustments" + +/** + * The details of the draft order to refresh the adjustments for. + */ +export interface ComputeDraftOrderAdjustmentsWorkflowInput { + /** + * The ID of the draft order to refresh the adjustments for. + */ + order_id: string +} + +/** + * This workflow computes the adjustments or promotions for a draft order. It's used by other workflows + * to compute new adjustments for the promotions whenever changes are made to the draft order. + * Created adjustments are "virtual" meaning they live on the action and no line item adjustments records are created + * in the database until the edit is confirmed. + * + * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around + * computing the adjustments for a draft order. + * + * @example + * const { result } = await computeDraftOrderAdjustmentsWorkflow(container) + * .run({ + * input: { + * order_id: "order_123", + * } + * }) + * + * @summary + * + * Refresh the promotions in a draft order. + */ +export const computeDraftOrderAdjustmentsWorkflow = createWorkflow( + computeDraftOrderAdjustmentsWorkflowId, + function (input: WorkflowData) { + acquireLockStep({ + key: input.order_id, + timeout: 2, + ttl: 10, + }) + + const order: OrderDTO & { promotions: PromotionDTO[] } = useRemoteQueryStep( + { + entry_point: "orders", + fields: draftOrderFieldsForRefreshSteps, + variables: { id: input.order_id }, + list: false, + throw_if_key_not_found: true, + } + ).config({ name: "order-query" }) + + const orderChange: OrderChangeDTO = useRemoteQueryStep({ + entry_point: "order_change", + fields: ["id", "status", "version", "actions.*"], + variables: { + filters: { + order_id: input.order_id, + status: [OrderChangeStatus.PENDING, OrderChangeStatus.REQUESTED], + }, + }, + list: false, + }).config({ name: "order-change-query" }) + + validateDraftOrderChangeStep({ order, orderChange }) + + const toDeleteActions = transform(orderChange, (orderChange) => { + return orderChange.actions + .filter( + (action) => + action.action === ChangeActionType.ITEM_ADJUSTMENTS_REPLACE + ) + .map((action) => { + return action.id + }) + }) + + when(toDeleteActions, (toDeleteActions) => toDeleteActions.length > 0).then( + () => { + // clean up old replace actions from the current order change + deleteOrderChangeActionsStep({ ids: toDeleteActions }) + } + ) + + const previewedOrder = previewOrderChangeStep(input.order_id) + + when( + { order }, + ({ order }) => Array.isArray(order.promotions) && !order.promotions.length + ).then(() => { + const orderChangeActionAdjustmentsInput = transform( + { order, previewedOrder, orderChange }, + ({ order, previewedOrder, orderChange }) => { + return previewedOrder.items.map((item) => { + return { + order_id: order.id, + order_change_id: orderChange.id, + version: orderChange.version, + action: ChangeActionType.ITEM_ADJUSTMENTS_REPLACE, + details: { + reference_id: item.id, + adjustments: [], + }, + } + }) + } + ) + + createOrderChangeActionsWorkflow + .runAsStep({ input: orderChangeActionAdjustmentsInput }) + .config({ name: "order-change-action-adjustments-input-remove" }) + }) + + when({ order }, ({ order }) => !!order.promotions?.length).then(() => { + const orderPromotions = transform({ order }, ({ order }) => { + return order.promotions + .map((p) => p.code) + .filter((p) => p !== undefined) + }) + + const actionsToComputeItemsInput = transform( + { previewedOrder, 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 actions = getActionsToComputeFromPromotionsStep({ + computeActionContext: actionsToComputeItemsInput, + promotionCodesToApply: orderPromotions, + }) + + const { lineItemAdjustmentsToCreate } = + prepareAdjustmentsFromPromotionActionsStep({ actions }) + + const orderChangeActionAdjustmentsInput = transform( + { + order, + previewedOrder, + 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, + 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" }) + }) + + releaseLockStep({ + key: input.order_id, + }) + + return new WorkflowResponse(void 0) + } +) diff --git a/packages/core/core-flows/src/draft-order/workflows/index.ts b/packages/core/core-flows/src/draft-order/workflows/index.ts index 3d51d8a7dd..3ca65940b9 100644 --- a/packages/core/core-flows/src/draft-order/workflows/index.ts +++ b/packages/core/core-flows/src/draft-order/workflows/index.ts @@ -5,6 +5,7 @@ export * from "./begin-draft-order-edit" export * from "./cancel-draft-order-edit" export * from "./confirm-draft-order-edit" export * from "./convert-draft-order" +export * from "./compute-draft-order-adjustments" export * from "./remove-draft-order-action-item" export * from "./remove-draft-order-action-shipping-method" export * from "./remove-draft-order-promotions" diff --git a/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts b/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts index 231372470b..efd90e5434 100644 --- a/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts +++ b/packages/core/core-flows/src/draft-order/workflows/refresh-draft-order-adjustments.ts @@ -51,6 +51,10 @@ export interface RefreshDraftOrderAdjustmentsWorkflowInput { * - Replace the existing promo codes with the new ones. */ action: PromotionActions + /** + * The version of the order change to refresh the adjustments for. + */ + version?: number } /** @@ -92,7 +96,7 @@ export const refreshDraftOrderAdjustmentsWorkflow = createWorkflow( }) const actions = getActionsToComputeFromPromotionsStep({ - cart: input.order as any, + computeActionContext: input.order as any, promotionCodesToApply, }) @@ -114,6 +118,7 @@ export const refreshDraftOrderAdjustmentsWorkflow = createWorkflow( createDraftOrderLineItemAdjustmentsStep({ lineItemAdjustmentsToCreate: lineItemAdjustmentsToCreate, order_id: input.order.id, + version: input.version, }), createDraftOrderShippingMethodAdjustmentsStep({ shippingMethodAdjustmentsToCreate: shippingMethodAdjustmentsToCreate, diff --git a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-item.ts b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-item.ts index edec987f43..61e5b58f95 100644 --- a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-item.ts +++ b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-item.ts @@ -1,4 +1,4 @@ -import { OrderChangeStatus, PromotionActions } from "@medusajs/framework/utils" +import { OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -20,8 +20,8 @@ import { import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validateDraftOrderRemoveActionItemStep } from "../steps/validate-draft-order-remove-action-item" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const removeDraftOrderActionItemWorkflowId = "remove-draft-order-action-item" @@ -109,11 +109,9 @@ export const removeDraftOrderActionItemWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: refetchedOrder, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-shipping-method.ts b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-shipping-method.ts index 4992bac85f..4154608aaf 100644 --- a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-shipping-method.ts +++ b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-action-shipping-method.ts @@ -1,4 +1,4 @@ -import { OrderChangeStatus, PromotionActions } from "@medusajs/framework/utils" +import { OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, parallelize, @@ -24,8 +24,8 @@ import { getDraftOrderPromotionContextStep } from "../steps/get-draft-order-prom import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validateDraftOrderShippingMethodActionStep } from "../steps/validate-draft-order-shipping-method-action" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const removeDraftOrderActionShippingMethodWorkflowId = "remove-draft-order-action-shipping-method" @@ -117,11 +117,9 @@ export const removeDraftOrderActionShippingMethodWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-promotions.ts b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-promotions.ts index 1cbfde0c1e..b5ffaff651 100644 --- a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-promotions.ts +++ b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-promotions.ts @@ -21,9 +21,10 @@ import { } from "../../order" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validatePromoCodesToRemoveStep } from "../steps/validate-promo-codes-to-remove" +import { updateDraftOrderPromotionsStep } from "../steps/update-draft-order-promotions" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const removeDraftOrderPromotionsWorkflowId = "remove-draft-order-promotions" @@ -83,7 +84,7 @@ export const removeDraftOrderPromotionsWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: input.order_id, @@ -111,11 +112,15 @@ export const removeDraftOrderPromotionsWorkflow = createWorkflow( promotions, }) - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + updateDraftOrderPromotionsStep({ + id: input.order_id, + promo_codes: input.promo_codes, + action: PromotionActions.REMOVE, + }) + + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order, - promo_codes: input.promo_codes, - action: PromotionActions.REMOVE, + order_id: input.order_id, }, }) diff --git a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-shipping-method.ts b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-shipping-method.ts index 90f2dcb185..3429f9ace1 100644 --- a/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-shipping-method.ts +++ b/packages/core/core-flows/src/draft-order/workflows/remove-draft-order-shipping-method.ts @@ -1,8 +1,4 @@ -import { - ChangeActionType, - OrderChangeStatus, - PromotionActions, -} from "@medusajs/framework/utils" +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -18,8 +14,8 @@ import { } from "../../order" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const removeDraftOrderShippingMethodWorkflowId = "remove-draft-order-shipping-method" @@ -103,19 +99,9 @@ export const removeDraftOrderShippingMethodWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - const refetchedOrder = useRemoteQueryStep({ - entry_point: "orders", - fields: draftOrderFieldsForRefreshSteps, - variables: { id: input.order_id }, - list: false, - throw_if_key_not_found: true, - }).config({ name: "refetched-order-query" }) - - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: refetchedOrder, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-item.ts b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-item.ts index 14ffc8f319..230791cb65 100644 --- a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-item.ts +++ b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-item.ts @@ -1,4 +1,4 @@ -import { OrderChangeStatus, PromotionActions } from "@medusajs/framework/utils" +import { OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -21,8 +21,8 @@ import { getDraftOrderPromotionContextStep } from "../steps/get-draft-order-prom import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validateDraftOrderUpdateActionItemStep } from "../steps/validate-draft-order-update-action-item" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const updateDraftOrderActionItemId = "update-draft-order-action-item" @@ -134,11 +134,9 @@ export const updateDraftOrderActionItemWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: context, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-shipping-method.ts b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-shipping-method.ts index 7d9e52d4df..34b4c94f39 100644 --- a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-shipping-method.ts +++ b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-action-shipping-method.ts @@ -1,4 +1,4 @@ -import { OrderChangeStatus, PromotionActions } from "@medusajs/framework/utils" +import { OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, parallelize, @@ -25,8 +25,8 @@ import { getDraftOrderPromotionContextStep } from "../steps/get-draft-order-prom import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { validateDraftOrderShippingMethodActionStep } from "../steps/validate-draft-order-shipping-method-action" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const updateDraftOrderActionShippingMethodWorkflowId = "update-draft-order-action-shipping-method" @@ -159,11 +159,9 @@ export const updateDraftOrderActionShippingMethodWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-item.ts b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-item.ts index 711496ad57..5a2fbab39a 100644 --- a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-item.ts +++ b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-item.ts @@ -3,7 +3,6 @@ import { ChangeActionType, MathBN, OrderChangeStatus, - PromotionActions, } from "@medusajs/framework/utils" import { createWorkflow, @@ -26,8 +25,8 @@ import { import { getDraftOrderPromotionContextStep } from "../steps/get-draft-order-promotion-context" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const updateDraftOrderItemWorkflowId = "update-draft-order-item" @@ -72,7 +71,7 @@ export const updateDraftOrderItemWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: input.order_id, @@ -133,11 +132,9 @@ export const updateDraftOrderItemWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: context, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-shipping-method.ts b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-shipping-method.ts index e3a0c91f22..ea005a9c4b 100644 --- a/packages/core/core-flows/src/draft-order/workflows/update-draft-order-shipping-method.ts +++ b/packages/core/core-flows/src/draft-order/workflows/update-draft-order-shipping-method.ts @@ -1,8 +1,4 @@ -import { - ChangeActionType, - OrderChangeStatus, - PromotionActions, -} from "@medusajs/framework/utils" +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { createWorkflow, transform, @@ -24,8 +20,8 @@ import { import { updateDraftOrderShippingMethodStep } from "../steps/update-draft-order-shipping-metod" import { validateDraftOrderChangeStep } from "../steps/validate-draft-order-change" import { draftOrderFieldsForRefreshSteps } from "../utils/fields" -import { refreshDraftOrderAdjustmentsWorkflow } from "./refresh-draft-order-adjustments" import { acquireLockStep, releaseLockStep } from "../../locking" +import { computeDraftOrderAdjustmentsWorkflow } from "./compute-draft-order-adjustments" export const updateDraftOrderShippingMethodWorkflowId = "update-draft-order-shipping-method" @@ -144,11 +140,9 @@ export const updateDraftOrderShippingMethodWorkflow = createWorkflow( appliedPromoCodes, (appliedPromoCodes) => appliedPromoCodes.length > 0 ).then(() => { - refreshDraftOrderAdjustmentsWorkflow.runAsStep({ + computeDraftOrderAdjustmentsWorkflow.runAsStep({ input: { - order: refetchedOrder, - promo_codes: appliedPromoCodes, - action: PromotionActions.REPLACE, + order_id: input.order_id, }, }) }) diff --git a/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts index 24f9de9b4d..d12dacb24c 100644 --- a/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts +++ b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts @@ -129,7 +129,7 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( return ( !!existingPaymentCollection?.id && !shouldRecreate && - MathBN.gt(amountPending, 0) + MathBN.gte(amountPending, 0) ) } ).then(() => { 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 91bfae38fe..15da5854b5 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 @@ -4,6 +4,7 @@ import { OrderExchangeDTO, OrderPreviewDTO, OrderWorkflow, + PromotionDTO, } from "@medusajs/framework/types" import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { @@ -21,6 +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 { updateOrderTaxLinesWorkflow } from "../update-tax-lines" import { refreshExchangeShippingWorkflow } from "./refresh-shipping" @@ -123,7 +125,14 @@ export const orderExchangeAddNewItemWorkflow = createWorkflow( const order: OrderDTO = useRemoteQueryStep({ entry_point: "orders", - fields: ["id", "status", "canceled_at", "items.*"], + fields: [ + "id", + "status", + "canceled_at", + "currency_code", + "items.*", + "promotions.*", + ], variables: { id: orderExchange.order_id }, list: false, throw_if_key_not_found: true, @@ -131,7 +140,7 @@ export const orderExchangeAddNewItemWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: orderExchange.order_id, @@ -192,6 +201,21 @@ export const orderExchangeAddNewItemWorkflow = createWorkflow( input: orderChangeActionInput, }) + const orderWithPromotions = transform({ order }, ({ order }) => { + return { + ...order, + promotions: (order as any).promotions ?? [], + } as OrderDTO & { promotions: PromotionDTO[] } + }) + + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order: orderWithPromotions, + orderChange, + exchange_id: orderExchange.id, + }, + }) + const refreshArgs = transform( { orderChange, orderExchange }, ({ orderChange, orderExchange }) => { 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 9a9bb4696c..118d3c648f 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 @@ -4,6 +4,7 @@ import { OrderExchangeDTO, OrderPreviewDTO, OrderWorkflow, + PromotionDTO, ReturnDTO, } from "@medusajs/framework/types" import { @@ -32,6 +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 { refreshExchangeShippingWorkflow } from "./refresh-shipping" /** @@ -178,10 +180,12 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow( fields: [ "id", "status", + "currency_code", "items.*", "items.variant.manage_inventory", "items.variant.inventory_items.inventory_item_id", "items.variant.inventory_items.inventory.location_levels.location_id", + "promotions.*", ], variables: { id: orderExchange.order_id }, list: false, @@ -190,7 +194,7 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow( const orderChange: OrderChangeDTO = useRemoteQueryStep({ entry_point: "order_change", - fields: ["id", "status"], + fields: ["id", "status", "version"], variables: { filters: { order_id: orderExchange.order_id, @@ -306,6 +310,21 @@ export const orderExchangeRequestItemReturnWorkflow = createWorkflow( input: orderChangeActionInput, }) + const orderWithPromotions = transform({ order }, ({ order }) => { + return { + ...order, + promotions: (order as any).promotions ?? [], + } as OrderDTO & { promotions: PromotionDTO[] } + }) + + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order: orderWithPromotions, + orderChange, + exchange_id: orderExchange.id, + }, + }) + const refreshArgs = transform( { orderChange, orderExchange }, ({ orderChange, orderExchange }) => { diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index 5b8425adfb..7da3479153 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -30,7 +30,6 @@ export * from "./decline-order-change" export * from "./delete-order-change" export * from "./delete-order-change-actions" export * from "./delete-order-payment-collection" -export * from "./fetch-shipping-option" export * from "./exchange/begin-order-exchange" export * from "./exchange/cancel-begin-order-exchange" export * from "./exchange/cancel-exchange" @@ -42,13 +41,16 @@ export * from "./exchange/remove-exchange-item-action" export * from "./exchange/remove-exchange-shipping-method" export * from "./exchange/update-exchange-add-item" export * from "./exchange/update-exchange-shipping-method" +export * from "./fetch-shipping-option" export * from "./get-order-detail" export * from "./get-orders-list" +export * from "./list-shipping-options-for-order" export * from "./mark-order-fulfillment-as-delivered" 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 "./order-edit/confirm-order-edit-request" export * from "./order-edit/create-order-edit-shipping-method" export * from "./order-edit/order-edit-add-new-item" @@ -87,4 +89,3 @@ export * from "./update-order" export * from "./update-order-change-actions" export * from "./update-order-changes" export * from "./update-tax-lines" -export * from "./list-shipping-options-for-order" diff --git a/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts b/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts index da04c9a4aa..d6d737ba7f 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts @@ -104,6 +104,8 @@ export const beginOrderEditOrderWorkflow = createWorkflow( } }) - return new WorkflowResponse(createOrderChangeStep(orderChangeInput)) + const orderChange = createOrderChangeStep(orderChangeInput) + + return new WorkflowResponse(orderChange) } ) 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 new file mode 100644 index 0000000000..5ea22e39ba --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/order-edit/compute-adjustments-for-preview.ts @@ -0,0 +1,163 @@ +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/confirm-order-edit-request.ts b/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts index 202b343eec..2c3a88260a 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/confirm-order-edit-request.ts @@ -6,26 +6,23 @@ import { } from "@medusajs/framework/types" import { ChangeActionType, + deduplicate, MathBN, OrderChangeStatus, OrderEditWorkflowEvents, } from "@medusajs/framework/utils" import { - WorkflowResponse, createStep, createWorkflow, transform, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { reserveInventoryStep } from "../../../cart/steps/reserve-inventory" import { prepareConfirmInventoryInput, requiredOrderFieldsForInventoryConfirmation, } from "../../../cart/utils/prepare-confirm-inventory-input" -import { - emitEventStep, - useQueryGraphStep, - useRemoteQueryStep, -} from "../../../common" +import { emitEventStep, useQueryGraphStep } from "../../../common" import { deleteReservationsByLineItemsStep } from "../../../reservation" import { previewOrderChangeStep } from "../../steps" import { confirmOrderChanges } from "../../steps/confirm-order-changes" @@ -176,22 +173,27 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( confirmed_by: input.confirmed_by, }) - const orderItems = useRemoteQueryStep({ - entry_point: "order", - fields: requiredOrderFieldsForInventoryConfirmation, - variables: { id: input.order_id }, - list: false, - throw_if_key_not_found: true, + const { data: refreshedOrder } = useQueryGraphStep({ + entity: "order", + fields: deduplicate([ + ...requiredOrderFieldsForInventoryConfirmation, + ...fieldsToRefreshOrderEdit, + ]), + filters: { id: input.order_id }, + options: { + throwIfKeyNotFound: true, + isList: false, + }, }).config({ name: "order-items-query" }) const { variants, items, toRemoveReservationLineItemIds } = transform( - { orderItems, previousOrderItems: order.items, orderPreview }, - ({ orderItems, previousOrderItems, orderPreview }) => { + { refreshedOrder, previousOrderItems: order.items, orderPreview }, + ({ refreshedOrder, previousOrderItems, orderPreview }) => { const allItems: any[] = [] const allVariants: any[] = [] const previousItemIds = (previousOrderItems || []).map(({ id }) => id) - const currentItemIds = orderItems.items.map(({ id }) => id) + const currentItemIds = refreshedOrder.items.map(({ id }) => id) const removedItemIds = previousItemIds.filter( (id) => !currentItemIds.includes(id) @@ -199,7 +201,7 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( const updatedItemIds: string[] = [] - orderItems.items.forEach((ordItem) => { + refreshedOrder.items.forEach((ordItem) => { const itemAction = orderPreview.items?.find( (item) => item.id === ordItem.id && @@ -214,13 +216,6 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( return } - const unitPrice: BigNumberInput = - itemAction.raw_unit_price ?? itemAction.unit_price - - const compareAtUnitPrice: BigNumberInput | undefined = - itemAction.raw_compare_at_unit_price ?? - itemAction.compare_at_unit_price - const updateAction = itemAction.actions!.find( (a) => a.action === ChangeActionType.ITEM_UPDATE ) @@ -241,8 +236,6 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( id: ordItem.id, variant_id: ordItem.variant_id, quantity: reservationQuantity, - unit_price: unitPrice, - compare_at_unit_price: compareAtUnitPrice, }) allVariants.push(ordItem.variant) }) @@ -261,7 +254,7 @@ export const confirmOrderEditRequestWorkflow = createWorkflow( const formatedInventoryItems = transform( { input: { - sales_channel_id: (orderItems as any).sales_channel_id, + sales_channel_id: (refreshedOrder as any).sales_channel_id, variants, items, }, 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 9fabb5c8dd..50171a09d9 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 @@ -4,16 +4,13 @@ import { OrderPreviewDTO, OrderWorkflow, } from "@medusajs/framework/types" +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { - ChangeActionType, - OrderChangeStatus -} from "@medusajs/framework/utils" -import { - WorkflowData, - WorkflowResponse, createStep, createWorkflow, transform, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../../common" import { previewOrderChangeStep } from "../../steps/preview-order-change" @@ -25,6 +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" /** * The data to validate that new items can be added to an order edit. @@ -158,7 +156,12 @@ export const orderEditAddNewItemWorkflow = createWorkflow( }) const orderChangeActionInput = transform( - { order, orderChange, items: input.items, lineItems }, + { + order, + orderChange, + items: input.items, + lineItems, + }, ({ order, orderChange, items, lineItems }) => { return items.map((item, index) => ({ order_change_id: orderChange.id, @@ -183,6 +186,13 @@ export const orderEditAddNewItemWorkflow = createWorkflow( input: orderChangeActionInput, }) + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order, + orderChange, + }, + }) + return new WorkflowResponse(previewOrderChangeStep(input.order_id)) } ) 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 b6bf98c379..57e8a14d9f 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 @@ -11,11 +11,11 @@ import { OrderChangeStatus, } from "@medusajs/framework/utils" import { - WorkflowData, - WorkflowResponse, createStep, createWorkflow, transform, + WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../../common" import { previewOrderChangeStep } from "../../steps/preview-order-change" @@ -24,6 +24,7 @@ import { throwIfOrderChangeIsNotActive, } from "../../utils/order-validation" import { createOrderChangeActionsWorkflow } from "../create-order-change-actions" +import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview" import { fieldsToRefreshOrderEdit } from "./utils/fields" /** @@ -80,6 +81,10 @@ export const orderEditUpdateItemQuantityWorkflowId = * This workflow updates the quantity of an existing item in an order's edit. It's used by the * [Update Order Item Quantity Admin API Route](https://docs.medusajs.com/api/admin#order-edits_postordereditsiditemsitemitem_id). * + * This workflow is different from the `updateOrderEditItemQuantityWorkflow` workflow in that this should be used + * when the item to update was part of the original order before the edit. The other workflow is for items + * that were added to the order as part of the edit. + * * You can also use this workflow to remove an item from an order by setting its quantity to `0`. * * You can use this workflow within your customizations or your own custom workflows, allowing you to update the quantity of an existing @@ -143,9 +148,13 @@ export const orderEditUpdateItemQuantityWorkflow = createWorkflow( }) const orderChangeActionInput = transform( - { order, orderChange, items: input.items }, + { + order, + orderChange, + items: input.items, + }, ({ order, orderChange, items }) => { - return items.map((item) => { + const itemsUpdates = items.map((item) => { const existing = order?.items?.find( (exItem) => exItem.id === item.id )! @@ -169,6 +178,8 @@ export const orderEditUpdateItemQuantityWorkflow = createWorkflow( }, } }) + + return [...itemsUpdates] } ) @@ -176,6 +187,13 @@ export const orderEditUpdateItemQuantityWorkflow = createWorkflow( input: orderChangeActionInput, }) + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order, + orderChange, + }, + }) + return new WorkflowResponse(previewOrderChangeStep(input.order_id)) } ) 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 aa107e44e7..f44f03970f 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 @@ -5,10 +5,7 @@ import { OrderPreviewDTO, OrderWorkflow, } from "@medusajs/framework/types" -import { - ChangeActionType, - OrderChangeStatus -} from "@medusajs/framework/utils" +import { ChangeActionType, OrderChangeStatus } from "@medusajs/framework/utils" import { WorkflowData, WorkflowResponse, @@ -25,6 +22,7 @@ import { throwIfIsCancelled, throwIfOrderChangeIsNotActive, } from "../../utils/order-validation" +import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview" import { fieldsToRefreshOrderEdit } from "./utils/fields" /** @@ -167,6 +165,13 @@ export const removeItemOrderEditActionWorkflow = createWorkflow( deleteOrderChangeActionsStep({ ids: [input.action_id] }) + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order, + orderChange, + }, + }) + return new WorkflowResponse(previewOrderChangeStep(order.id)) } ) 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 b534bb0c7f..272ba12b32 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,6 +22,7 @@ import { throwIfIsCancelled, throwIfOrderChangeIsNotActive, } from "../../utils/order-validation" +import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview" import { fieldsToRefreshOrderEdit } from "./utils/fields" /** @@ -185,6 +186,13 @@ export const updateOrderEditAddItemWorkflow = createWorkflow( updateOrderChangeActionsStep([updateData]) + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order, + orderChange, + }, + }) + return new WorkflowResponse(previewOrderChangeStep(order.id)) } ) 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 a7533a56f8..6ee6d094e8 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,6 +22,7 @@ import { throwIfIsCancelled, throwIfOrderChangeIsNotActive, } from "../../utils/order-validation" +import { computeAdjustmentsForPreviewWorkflow } from "./compute-adjustments-for-preview" import { fieldsToRefreshOrderEdit } from "./utils/fields" /** @@ -105,6 +106,9 @@ export const updateOrderEditItemQuantityWorkflowId = "update-order-edit-update-quantity" /** * This workflow updates an existing order item that was previously added to the order edit. + * It is different from the `orderEditUpdateItemQuantityWorkflow` workflow in that this should be used + * when the item to update was added as part of the order edit. The other workflow is for items + * that were already in the order before the edit. * * You can use this workflow within your customizations or your own custom workflows, allowing you to update the quantity * of an existing item in an order edit in your custom flows. @@ -185,6 +189,13 @@ export const updateOrderEditItemQuantityWorkflow = createWorkflow( updateOrderChangeActionsStep([updateData]) + computeAdjustmentsForPreviewWorkflow.runAsStep({ + input: { + order, + orderChange, + }, + }) + return new WorkflowResponse(previewOrderChangeStep(order.id)) } ) diff --git a/packages/core/core-flows/src/order/workflows/order-edit/utils/fields.ts b/packages/core/core-flows/src/order/workflows/order-edit/utils/fields.ts index 948203b41e..d60812fe8d 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/utils/fields.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/utils/fields.ts @@ -4,8 +4,13 @@ export const fieldsToRefreshOrderEdit = [ "version", "currency_code", "canceled_at", - "items.*", - "items.product.id", "promotions.*", + "subtotal", + "items.*", + "items.subtotal", + "items.product.id", + "items.adjustments.*", + "shipping_methods.*", + "shipping_methods.adjustments.*", "shipping_address.*", ] diff --git a/packages/core/core-flows/src/order/workflows/update-tax-lines.ts b/packages/core/core-flows/src/order/workflows/update-tax-lines.ts index c3689418c2..1e726d79f4 100644 --- a/packages/core/core-flows/src/order/workflows/update-tax-lines.ts +++ b/packages/core/core-flows/src/order/workflows/update-tax-lines.ts @@ -4,6 +4,7 @@ import { transform, when, WorkflowData, + WorkflowResponse, } from "@medusajs/framework/workflows-sdk" import { useQueryGraphStep } from "../../common" import { getItemTaxLinesStep } from "../../tax/steps/get-item-tax-lines" @@ -179,9 +180,7 @@ export const updateOrderTaxLinesWorkflowId = "update-order-tax-lines" */ export const updateOrderTaxLinesWorkflow = createWorkflow( updateOrderTaxLinesWorkflowId, - ( - input: WorkflowData - ): WorkflowData => { + (input: WorkflowData) => { const isFullOrder = transform(input, (data) => { return !data.item_ids && !data.shipping_method_ids }) @@ -209,9 +208,13 @@ export const updateOrderTaxLinesWorkflow = createWorkflow( return orderLineItems }) - const shippingMethods = when("get-order-shipping-methods", { input }, ({ input }) => { - return input.shipping_method_ids!?.length > 0 - }).then(() => { + const shippingMethods = when( + "get-order-shipping-methods", + { input }, + ({ input }) => { + return input.shipping_method_ids!?.length > 0 + } + ).then(() => { const { data: orderShippingMethods } = useQueryGraphStep({ entity: "order_shipping_method", filters: { id: input.shipping_method_ids }, @@ -250,5 +253,10 @@ export const updateOrderTaxLinesWorkflow = createWorkflow( item_tax_lines: taxLineItems.lineItemTaxLines, shipping_tax_lines: taxLineItems.shippingMethodsTaxLines, }) + + return new WorkflowResponse({ + itemTaxLines: taxLineItems.lineItemTaxLines, + shippingTaxLines: taxLineItems.shippingMethodsTaxLines, + }) } ) diff --git a/packages/core/framework/package.json b/packages/core/framework/package.json index c4b3b7553e..fcf07da137 100644 --- a/packages/core/framework/package.json +++ b/packages/core/framework/package.json @@ -80,7 +80,7 @@ "@medusajs/workflows-sdk": "2.11.3", "@types/express": "^4.17.21", "chokidar": "^3.5.3", - "compression": "^1.8.0", + "compression": "^1.8.1", "connect-redis": "5.2.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/packages/core/types/src/order/common.ts b/packages/core/types/src/order/common.ts index d43d2cb822..963efdf447 100644 --- a/packages/core/types/src/order/common.ts +++ b/packages/core/types/src/order/common.ts @@ -30,6 +30,7 @@ export type ChangeActionType = | "CREDIT_LINE_ADD" | "PROMOTION_ADD" | "PROMOTION_REMOVE" + | "ITEM_ADJUSTMENTS_REPLACE" export type OrderChangeStatus = | "confirmed" @@ -2310,6 +2311,11 @@ export interface OrderChangeActionDTO { */ internal_note: string | null + /** + * The ordering of the order change action + */ + ordering: number + /** * When the order change action was created */ diff --git a/packages/core/types/src/order/mutations.ts b/packages/core/types/src/order/mutations.ts index 0e795a630e..e2bfeabf2f 100644 --- a/packages/core/types/src/order/mutations.ts +++ b/packages/core/types/src/order/mutations.ts @@ -301,6 +301,11 @@ export interface CreateOrderAdjustmentDTO { * Whether the adjustment is tax inclusive. */ is_tax_inclusive?: boolean + + /** + * The version of the adjustment. + */ + version?: number } /** diff --git a/packages/core/types/src/promotion/common/compute-actions.ts b/packages/core/types/src/promotion/common/compute-actions.ts index d9b186629d..21c1b4bfad 100644 --- a/packages/core/types/src/promotion/common/compute-actions.ts +++ b/packages/core/types/src/promotion/common/compute-actions.ts @@ -90,6 +90,11 @@ export interface RemoveItemAdjustmentAction { */ adjustment_id: string + /** + * The associated item's ID. + */ + item_id: string + /** * The promotion's description. */ @@ -145,6 +150,11 @@ export interface RemoveShippingMethodAdjustment { */ adjustment_id: string + /** + * The associated shipping method's ID. + */ + shipping_method_id: string + /** * The promotion's code. */ @@ -244,7 +254,7 @@ export interface ComputeActionContext extends Record { /** * The cart's email - * + * * @since 2.11.0 */ email?: string diff --git a/packages/core/utils/src/order/order-change-action.ts b/packages/core/utils/src/order/order-change-action.ts index 67c8ea57f4..6258237ce4 100644 --- a/packages/core/utils/src/order/order-change-action.ts +++ b/packages/core/utils/src/order/order-change-action.ts @@ -20,4 +20,5 @@ export enum ChangeActionType { CREDIT_LINE_ADD = "CREDIT_LINE_ADD", PROMOTION_ADD = "PROMOTION_ADD", PROMOTION_REMOVE = "PROMOTION_REMOVE", + ITEM_ADJUSTMENTS_REPLACE = "ITEM_ADJUSTMENTS_REPLACE", } diff --git a/packages/core/utils/src/totals/cart/index.ts b/packages/core/utils/src/totals/cart/index.ts index 5c3e65b2e4..16fae73892 100644 --- a/packages/core/utils/src/totals/cart/index.ts +++ b/packages/core/utils/src/totals/cart/index.ts @@ -214,7 +214,6 @@ export function decorateCartTotals( shippingOriginalSubtotal ) - // TODO: subtract (cart.gift_card_total + cart.gift_card_tax_total) const tempTotal = MathBN.add(subtotal, taxTotal) const total = MathBN.sub(tempTotal, discountSubtotal, creditLinesTotal) diff --git a/packages/core/utils/src/totals/line-item/index.ts b/packages/core/utils/src/totals/line-item/index.ts index efa7f0bc6e..67aa77537e 100644 --- a/packages/core/utils/src/totals/line-item/index.ts +++ b/packages/core/utils/src/totals/line-item/index.ts @@ -107,7 +107,7 @@ function setRefundableTotal( totals.refundable_total = new BigNumber(refundableTotal) } -function getLineItemTotals( +export function getLineItemTotals( item: GetItemTotalInput, context: GetLineItemsTotalsContext ) { diff --git a/packages/medusa/src/api/admin/draft-orders/[id]/edit/promotions/route.ts b/packages/medusa/src/api/admin/draft-orders/[id]/edit/promotions/route.ts index 7d68d2c2e3..ca646c195d 100644 --- a/packages/medusa/src/api/admin/draft-orders/[id]/edit/promotions/route.ts +++ b/packages/medusa/src/api/admin/draft-orders/[id]/edit/promotions/route.ts @@ -33,7 +33,6 @@ export const DELETE = async ( ) => { const { id } = req.params - const { result } = await removeDraftOrderPromotionsWorkflow(req.scope).run({ input: { ...req.validatedBody, diff --git a/packages/modules/order/integration-tests/__tests__/order-edit.spec.ts b/packages/modules/order/integration-tests/__tests__/order-edit.spec.ts index 3abf7af20a..61a145e9fd 100644 --- a/packages/modules/order/integration-tests/__tests__/order-edit.spec.ts +++ b/packages/modules/order/integration-tests/__tests__/order-edit.spec.ts @@ -868,6 +868,86 @@ moduleIntegrationTestRunner({ }) ) }) + + it("should create an order change, update items, and have the pending difference updated", async function () { + const createdOrder = await service.createOrders({ + email: "foo@bar.com", + items: [ + { + title: "Item 1", + subtitle: "Subtitle 1", + thumbnail: "thumbnail1.jpg", + quantity: new BigNumber(1), + product_id: "product1", + product_title: "Product 1", + product_description: "Description 1", + product_subtitle: "Product Subtitle 1", + product_type: "Type 1", + product_collection: "Collection 1", + product_handle: "handle1", + variant_id: "variant1", + variant_sku: "SKU1", + variant_barcode: "Barcode1", + variant_title: "Variant 1", + variant_option_values: { + color: "Red", + size: "Large", + }, + requires_shipping: true, + is_discountable: true, + is_tax_inclusive: true, + compare_at_unit_price: 10, + unit_price: 10, + tax_lines: [], + adjustments: [ + { + code: "VIP_10", + amount: 1, + description: "VIP discount", + promotion_id: "prom_123", + provider_id: "coupon_kings", + }, + ], + }, + ], + sales_channel_id: "test", + transactions: [ + { + amount: 9, + currency_code: "USD", + reference: "payment", + reference_id: "pay_123", + }, + ], + currency_code: "usd", + customer_id: "joe", + } as CreateOrderDTO) + + const orderChange = await service.createOrderChange({ + order_id: createdOrder.id, + actions: [ + { + action: ChangeActionType.ITEM_UPDATE, + details: { + reference_id: createdOrder.items![0].id, + quantity: 0, + }, + }, + ], + }) + + await service.confirmOrderChange({ + id: orderChange.id, + }) + + const changedOrder = await service.retrieveOrder(createdOrder.id, { + select: ["total", "summary", "total"], + relations: ["items"], + }) + + // @ts-ignore + expect(changedOrder.summary?.pending_difference.numeric).toEqual(-9) + }) }) }, }) diff --git a/packages/modules/order/src/migrations/.snapshot-medusa-order.json b/packages/modules/order/src/migrations/.snapshot-medusa-order.json index 7efe233a3e..91b2e0c5ae 100644 --- a/packages/modules/order/src/migrations/.snapshot-medusa-order.json +++ b/packages/modules/order/src/migrations/.snapshot-medusa-order.json @@ -1978,6 +1978,15 @@ "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": [], @@ -2048,6 +2057,16 @@ "nullable": false, "mappedType": "text" }, + "version": { + "name": "version", + "type": "integer", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "1", + "mappedType": "integer" + }, "description": { "name": "description", "type": "text", diff --git a/packages/modules/order/src/migrations/Migration20251016160403.ts b/packages/modules/order/src/migrations/Migration20251016160403.ts new file mode 100644 index 0000000000..9afad5360f --- /dev/null +++ b/packages/modules/order/src/migrations/Migration20251016160403.ts @@ -0,0 +1,33 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20251016160403 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "order_line_item_adjustment" add column if not exists "version" integer not null default 1;` + ) + + this.addSql(` + WITH latest_order_item_version AS ( + SELECT + oli.id AS item_id, + MAX(oi.version) AS version + FROM "order_line_item" oli + INNER JOIN "order_item" oi + ON oi.item_id = oli.id + AND oi.deleted_at IS NULL + GROUP BY oli.id + ) + UPDATE "order_line_item_adjustment" olia + SET version = latest_order_item_version.version + FROM latest_order_item_version + WHERE olia.item_id = latest_order_item_version.item_id + AND olia.version <> latest_order_item_version.version; + `) + } + + override async down(): Promise { + this.addSql( + `alter table if exists "order_line_item_adjustment" drop column if exists "version";` + ) + } +} diff --git a/packages/modules/order/src/models/line-item-adjustment.ts b/packages/modules/order/src/models/line-item-adjustment.ts index 832ac619b1..5cdd17ffe6 100644 --- a/packages/modules/order/src/models/line-item-adjustment.ts +++ b/packages/modules/order/src/models/line-item-adjustment.ts @@ -4,6 +4,7 @@ import { OrderLineItem } from "./line-item" const _OrderLineItemAdjustment = model .define("OrderLineItemAdjustment", { id: model.id({ prefix: "ordliadj" }).primaryKey(), + version: model.number().default(1), description: model.text().nullable(), promotion_id: model.text().nullable(), code: model.text().nullable(), diff --git a/packages/modules/order/src/services/order-module-service.ts b/packages/modules/order/src/services/order-module-service.ts index 686cd1364e..788f602c23 100644 --- a/packages/modules/order/src/services/order-module-service.ts +++ b/packages/modules/order/src/services/order-module-service.ts @@ -2540,19 +2540,29 @@ export default class OrderModuleService sharedContext ) - const { itemsToUpsert, shippingMethodsToUpsert, calculatedOrders } = - await applyChangesToOrder( - [order], - { [order.id]: orderChange.actions }, - { addActionReferenceToObject: true } - ) + // We need to apply the latest ordering actions last + const sortedActions = orderChange.actions.sort((a, b) => { + return a.ordering - b.ordering + }) + + const { + itemsToUpsert, + shippingMethodsToUpsert, + calculatedOrders, + lineItemAdjustmentsToCreate, + } = await applyChangesToOrder( + [order], + { [order.id]: sortedActions }, + { addActionReferenceToObject: true } + ) const calculated = calculatedOrders[order.id] - await this.includeTaxLinesAndAdjustementsToPreview( + await this.includeTaxLinesAndAdjustmentsToPreview( calculated.order, itemsToUpsert, shippingMethodsToUpsert, + lineItemAdjustmentsToCreate, // this will add "virtual" adjustments for the preview version but no actual adjustments will be created in the DB sharedContext ) @@ -2568,10 +2578,11 @@ export default class OrderModuleService return calcOrder } - private async includeTaxLinesAndAdjustementsToPreview( + private async includeTaxLinesAndAdjustmentsToPreview( order, itemsToUpsert, shippingMethodsToUpsert, + lineItemAdjustmentsToCreate, sharedContext: Context = {} ) { const addedItems = {} @@ -2619,6 +2630,11 @@ export default class OrderModuleService //@ts-ignore const newItem = itemsToUpsert.find((d) => d.item_id === item.id)! + + const adjustments = lineItemAdjustmentsToCreate.filter( + (d) => d.item_id === newItem.item_id + ) + const unitPrice = newItem?.unit_price ?? item.unit_price const compareAtUnitPrice = newItem?.compare_at_unit_price ?? item.compare_at_unit_price @@ -2632,6 +2648,7 @@ export default class OrderModuleService quantity: newItem.quantity, unit_price: unitPrice, compare_at_unit_price: compareAtUnitPrice || null, + adjustments: adjustments, detail: { ...newItem, ...item, @@ -3539,11 +3556,12 @@ export default class OrderModuleService summariesToUpsert, orderToUpdate, creditLinesToUpsert, + lineItemAdjustmentsToCreate, } = await applyChangesToOrder(orders, actionsMap, { addActionReferenceToObject: true, - includeTaxLinesAndAdjustementsToPreview: async (...args) => { + includeTaxLinesAndAdjustmentsToPreview: async (...args) => { args.push(sharedContext) - return await this.includeTaxLinesAndAdjustementsToPreview.apply( + return await this.includeTaxLinesAndAdjustmentsToPreview.apply( this, args ) @@ -3582,6 +3600,14 @@ export default class OrderModuleService sharedContext ) : null, + lineItemAdjustmentsToCreate.length + ? this.orderLineItemAdjustmentService_.create( + // this is called when a new order version is confirmed so we only create a new set of adjustments for that version + // there is no removal or upsert + lineItemAdjustmentsToCreate, + sharedContext + ) + : null, ]) return { diff --git a/packages/modules/order/src/types/utils/index.ts b/packages/modules/order/src/types/utils/index.ts index 758371d7c2..ad0321d046 100644 --- a/packages/modules/order/src/types/utils/index.ts +++ b/packages/modules/order/src/types/utils/index.ts @@ -1,6 +1,8 @@ import { BigNumberInput, CreateOrderCreditLineDTO, + LineItemAdjustmentDTO, + LineItemTaxLineDTO, OrderCreditLineDTO, } from "@medusajs/framework/types" @@ -13,10 +15,13 @@ export type VirtualOrder = { return_id?: string claim_id?: string exchange_id?: string + is_tax_inclusive?: boolean unit_price: BigNumberInput compare_at_unit_price: BigNumberInput | null quantity: BigNumberInput + adjustments?: (LineItemAdjustmentDTO & { version: number })[] + tax_lines?: LineItemTaxLineDTO[] detail: { id?: string @@ -24,7 +29,6 @@ export type VirtualOrder = { return_id?: string claim_id?: string exchange_id?: string - item_id?: string unit_price?: BigNumberInput compare_at_unit_price?: BigNumberInput | null diff --git a/packages/modules/order/src/utils/actions/index.ts b/packages/modules/order/src/utils/actions/index.ts index fe9b0ddaa9..cdaf9dc0d0 100644 --- a/packages/modules/order/src/utils/actions/index.ts +++ b/packages/modules/order/src/utils/actions/index.ts @@ -5,6 +5,7 @@ export * from "./credit-line-add" export * from "./deliver-item" export * from "./fulfill-item" export * from "./item-add" +export * from "./item-adjustments-replace" export * from "./item-remove" export * from "./item-update" export * from "./promotion-add" @@ -19,3 +20,4 @@ export * from "./shipping-remove" export * from "./shipping-update" export * from "./transfer-customer" export * from "./write-off-item" + diff --git a/packages/modules/order/src/utils/actions/item-add.ts b/packages/modules/order/src/utils/actions/item-add.ts index 1ac7f6c8d8..f3b2cbd604 100644 --- a/packages/modules/order/src/utils/actions/item-add.ts +++ b/packages/modules/order/src/utils/actions/item-add.ts @@ -29,7 +29,6 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_ADD, { return_id: action.return_id, claim_id: action.claim_id, exchange_id: action.exchange_id, - unit_price: action.details.unit_price, compare_at_unit_price: action.details.compare_at_unit_price, quantity: action.details.quantity, diff --git a/packages/modules/order/src/utils/actions/item-adjustments-replace.ts b/packages/modules/order/src/utils/actions/item-adjustments-replace.ts new file mode 100644 index 0000000000..359445915c --- /dev/null +++ b/packages/modules/order/src/utils/actions/item-adjustments-replace.ts @@ -0,0 +1,32 @@ +import { ChangeActionType, MedusaError } from "@medusajs/framework/utils" +import { OrderChangeProcessing } from "../calculate-order-change" +import { setActionReference } from "../set-action-reference" + +OrderChangeProcessing.registerActionType( + ChangeActionType.ITEM_ADJUSTMENTS_REPLACE, + { + operation({ action, currentOrder, options }) { + let existing = currentOrder.items.find( + (item) => item.id === action.details.reference_id + ) + + if (!existing) { + return + } + + existing.adjustments = action.details.adjustments ?? [] + + setActionReference(existing, action, options) + }, + validate({ action }) { + const refId = action.details?.reference_id + + if (!action.details.adjustments) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Adjustments of item ${refId} must exist.` + ) + } + }, + } +) diff --git a/packages/modules/order/src/utils/actions/item-update.ts b/packages/modules/order/src/utils/actions/item-update.ts index 33e94d8acc..8212ddf8c5 100644 --- a/packages/modules/order/src/utils/actions/item-update.ts +++ b/packages/modules/order/src/utils/actions/item-update.ts @@ -27,6 +27,10 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_UPDATE, { existing.quantity = currentQuantity existing.detail.quantity = currentQuantity + if (action.details.adjustments) { + existing.adjustments = action.details.adjustments + } + if (action.details.unit_price) { const currentUnitPrice = MathBN.convert(action.details.unit_price) const originalTotal = MathBN.mult(originalUnitPrice, originalQuantity) diff --git a/packages/modules/order/src/utils/apply-order-changes.ts b/packages/modules/order/src/utils/apply-order-changes.ts index 63d61eace9..ba3954441f 100644 --- a/packages/modules/order/src/utils/apply-order-changes.ts +++ b/packages/modules/order/src/utils/apply-order-changes.ts @@ -1,4 +1,5 @@ import { + CreateOrderLineItemAdjustmentDTO, InferEntityType, OrderChangeActionDTO, OrderDTO, @@ -25,13 +26,14 @@ export async function applyChangesToOrder( actionsMap: Record, options?: { addActionReferenceToObject?: boolean - includeTaxLinesAndAdjustementsToPreview?: (...args) => void + includeTaxLinesAndAdjustmentsToPreview?: (...args) => void } ) { const itemsToUpsert: InferEntityType[] = [] const creditLinesToUpsert: InferEntityType[] = [] const shippingMethodsToUpsert: InferEntityType[] = [] + const lineItemAdjustmentsToCreate: CreateOrderLineItemAdjustmentDTO[] = [] const summariesToUpsert: any[] = [] const orderToUpdate: any[] = [] @@ -94,6 +96,20 @@ export async function applyChangesToOrder( metadata: orderItem.metadata, } as any + if (version > order.version) { + item.adjustments?.forEach((adjustment) => { + lineItemAdjustmentsToCreate.push({ + item_id: itemId, + version, + amount: adjustment.amount, + description: adjustment.description, + promotion_id: adjustment.promotion_id, + code: adjustment.code, + is_tax_inclusive: adjustment.is_tax_inclusive, + }) + }) + } + itemsToUpsert.push(itemToUpsert) } @@ -157,11 +173,12 @@ export async function applyChangesToOrder( } // Including tax lines and adjustments for added items and shipping methods - if (options?.includeTaxLinesAndAdjustementsToPreview) { - await options?.includeTaxLinesAndAdjustementsToPreview( + if (options?.includeTaxLinesAndAdjustmentsToPreview) { + await options?.includeTaxLinesAndAdjustmentsToPreview( calculated.order, itemsToUpsert, - shippingMethodsToUpsert + shippingMethodsToUpsert, + lineItemAdjustmentsToCreate ) decorateCartTotals(calculated.order) } @@ -194,6 +211,7 @@ export async function applyChangesToOrder( } return { + lineItemAdjustmentsToCreate, itemsToUpsert, creditLinesToUpsert, shippingMethodsToUpsert, diff --git a/packages/modules/order/src/utils/base-repository-find.ts b/packages/modules/order/src/utils/base-repository-find.ts index b1d5e504fe..04fac94ab3 100644 --- a/packages/modules/order/src/utils/base-repository-find.ts +++ b/packages/modules/order/src/utils/base-repository-find.ts @@ -1,7 +1,8 @@ import { Constructor, Context, DAL } from "@medusajs/framework/types" import { toMikroORMEntity } from "@medusajs/framework/utils" import { LoadStrategy } from "@medusajs/framework/mikro-orm/core" -import { Order, OrderClaim } from "@models" +import { Order, OrderClaim, OrderLineItemAdjustment } from "@models" + import { mapRepositoryToOrderModel } from "." export function setFindMethods(klass: Constructor, entity: any) { @@ -76,13 +77,43 @@ export function setFindMethods(klass: Constructor, entity: any) { configurePopulateWhere(config, isRelatedEntity, version) + let loadAdjustments = false + if (config.options.populate.includes("items.item.adjustments")) { + // TODO: handle if populate is an object + loadAdjustments = true + config.options.populate.splice( + config.options.populate.indexOf("items.item.adjustments"), + 1 + ) + + config.options.populate.push("items") + config.options.populate.push("items.item") + + // make sure version is loaded if adjustments are requested + if (config.options.fields?.some((f) => f.includes("items.item."))) { + config.options.fields.push( + isRelatedEntity ? "order.items.version" : "items.version" + ) + } + } + if (!config.options.orderBy) { config.options.orderBy = { id: "ASC" } } config.where ??= {} - return await manager.find(this.entity, config.where, config.options) + const result = await manager.find(this.entity, config.where, config.options) + + if (loadAdjustments) { + const orders = !isRelatedEntity + ? [...result] + : [...result].map((r) => r.order).filter(Boolean) + + await loadItemAdjustments(manager, orders) + } + + return result } klass.prototype.findAndCount = async function findAndCount( @@ -136,6 +167,25 @@ export function setFindMethods(klass: Constructor, entity: any) { const version = config.where.version ?? defaultVersion delete config.where.version + let loadAdjustments = false + if (config.options.populate.includes("items.item.adjustments")) { + loadAdjustments = true + config.options.populate.splice( + config.options.populate.indexOf("items.item.adjustments"), + 1 + ) + + config.options.populate.push("items") + config.options.populate.push("items.item") + + // make sure version is loaded if adjustments are requested + if (config.options.fields?.some((f) => f.includes("items.item."))) { + config.options.fields.push( + isRelatedEntity ? "order.items.version" : "items.version" + ) + } + } + configurePopulateWhere( config, isRelatedEntity, @@ -148,7 +198,57 @@ export function setFindMethods(klass: Constructor, entity: any) { config.options.orderBy = { id: "ASC" } } - return await manager.findAndCount(this.entity, config.where, config.options) + const [result, count] = await manager.findAndCount( + this.entity, + config.where, + config.options + ) + + if (loadAdjustments) { + const orders = !isRelatedEntity + ? [...result] + : [...result].map((r) => r.order).filter(Boolean) + + await loadItemAdjustments(manager, orders) + } + + return [result, count] + } +} + +/** + * Load adjustment for the lates items/order version + * @param manager MikroORM manager + * @param orders Orders to load adjustments for + */ +async function loadItemAdjustments(manager, orders) { + const items = orders.flatMap((r) => [...(r.items ?? [])]) + const itemsIdMap = new Map(items.map((i) => [i.item.id, i.item])) + + const params = items.map((i) => { + // preinitialise all items so an empty array is returned for ones without adjustments + if (!i.item.adjustments.isInitialized()) { + i.item.adjustments.initialized = true + } + + if (!i.version) { + throw new Error("Item version is required to load adjustments") + } + return { + item_id: i.item.id, + version: i.version, + } + }) + + const adjustments = await manager.find(OrderLineItemAdjustment, { + $or: params, + }) + + for (const adjustment of adjustments) { + const item = itemsIdMap.get(adjustment.item_id) + if (item) { + item.adjustments.add(adjustment) + } } } diff --git a/packages/modules/order/src/utils/calculate-order-change.ts b/packages/modules/order/src/utils/calculate-order-change.ts index 84abcd12bf..15d5acf26a 100644 --- a/packages/modules/order/src/utils/calculate-order-change.ts +++ b/packages/modules/order/src/utils/calculate-order-change.ts @@ -228,6 +228,8 @@ export class OrderChangeProcessing { public getSummaryFromOrder(order: OrderDTO): OrderSummaryDTO { const summary_ = this.summary const total = order.total + // const pendingDifference = MathBN.sub(total, summary_.transaction_total) + const orderSummary = { transaction_total: new BigNumber(summary_.transaction_total), original_order_total: new BigNumber(summary_.original_order_total), diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index c687730765..12688def65 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -725,6 +725,7 @@ export default class PromotionModuleService action: ComputedActions.REMOVE_ITEM_ADJUSTMENT, adjustment_id: adjustment.id, code, + item_id: adjustment.item_id as string, }) } @@ -733,11 +734,12 @@ export default class PromotionModuleService action: ComputedActions.REMOVE_SHIPPING_METHOD_ADJUSTMENT, adjustment_id: adjustment.id, code, + shipping_method_id: adjustment.shipping_method_id as string, }) } } - const promotionCodeSet = new Set(promotionCodes) + const promotionCodeSet = new Set(promotionCodes) // TODO: uniquePromotionCodes const automaticPromotionCodeSet = new Set(automaticPromotionCodes) const sortedPromotionsToApply = promotions diff --git a/yarn.lock b/yarn.lock index 377fd0c08e..631604a899 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3584,7 +3584,7 @@ __metadata: "@medusajs/workflows-sdk": 2.11.3 "@types/express": ^4.17.21 chokidar: ^3.5.3 - compression: ^1.8.0 + compression: ^1.8.1 connect-redis: 5.2.0 cookie-parser: ^1.4.6 cors: ^2.8.5 @@ -14563,7 +14563,7 @@ __metadata: languageName: node linkType: hard -"compression@npm:^1.8.0": +"compression@npm:^1.8.0, compression@npm:^1.8.1": version: 1.8.1 resolution: "compression@npm:1.8.1" dependencies: