feat(core-flows, types): calculated shipping in RMA flows (#11533)

* wip: calculated SO pricing in RMA flows

* fix: types

* chore: small refactor

* feat: caluclated shipping in return flow

* fix: module integrations

* fix: array containing

* feat: refresh shipping on update item quantity

* rm: log

* rm: log2

* feat: update interface, remove flag

* fix: revert change on OE for now

* fix: import

* feat: refactor flwos, cleanup cacluation cotext data model, wip exchanges

* feat: refreshing inbound/outbound shipping on items change

* feat: refresh exchange shipping on return item add, test

* feat: refresh shipping on exchange/return item remove

* fix: check optional

* feat: test recalculation on quantity update

* feat: calculated shipping on claims

* fix: comment

* wip: address comments

* fix: more remote query, fix build

* refactor: claim refresh workflow

* fix: remove throw option

* fix: deconstruct param

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-03-24 07:07:47 +01:00
committed by GitHub
parent 053326950d
commit de8c034d1b
33 changed files with 1997 additions and 219 deletions

View File

@@ -33,9 +33,15 @@ medusaIntegrationTestRunner({
)
expect(response.status).toEqual(200)
expect(response.data.fulfillment_providers).toEqual([
{ id: "manual_test-provider", is_enabled: true },
])
expect(response.data.fulfillment_providers).toEqual(
expect.arrayContaining([
{ id: "manual_test-provider", is_enabled: true },
{
id: "manual-calculated_test-provider-calculated",
is_enabled: true,
},
])
)
})
})
},

View File

@@ -13,14 +13,22 @@ import {
} from "@medusajs/utils"
const providerId = "manual_test-provider"
const providerIdCalculated = "manual-calculated_test-provider-calculated"
export async function prepareDataFixtures({ container }) {
const fulfillmentService = container.resolve(Modules.FULFILLMENT)
const salesChannelService = container.resolve(Modules.SALES_CHANNEL)
const stockLocationModule: IStockLocationService = container.resolve(
Modules.STOCK_LOCATION
)
const pricingModule = container.resolve(Modules.PRICING)
const productModule = container.resolve(Modules.PRODUCT)
const inventoryModule = container.resolve(Modules.INVENTORY)
const customerService = container.resolve(Modules.CUSTOMER)
const customer = await customerService.createCustomers({
email: "foo@bar.com",
})
const shippingProfile = await fulfillmentService.createShippingProfiles({
name: "test",
@@ -71,6 +79,18 @@ export async function prepareDataFixtures({ container }) {
},
})
const priceSets = await pricingModule.createPriceSets([
{
prices: [
{
amount: 10,
region_id: region.id,
currency_code: "usd",
},
],
},
])
const [product] = await productModule.createProducts([
{
title: "Test product",
@@ -91,7 +111,7 @@ export async function prepareDataFixtures({ container }) {
{
inventory_item_id: inventoryItem.id,
location_id: location.id,
stocked_quantity: 2,
stocked_quantity: 10,
reserved_quantity: 0,
},
])
@@ -123,6 +143,14 @@ export async function prepareDataFixtures({ container }) {
inventory_item_id: inventoryItem.id,
},
},
{
[Modules.PRODUCT]: {
variant_id: product.variants[0].id,
},
[Modules.PRICING]: {
price_set_id: priceSets[0].id,
},
},
])
await remoteLink.create([
@@ -131,7 +159,16 @@ export async function prepareDataFixtures({ container }) {
stock_location_id: location.id,
},
[Modules.FULFILLMENT]: {
fulfillment_provider_id: "manual_test-provider",
fulfillment_provider_id: providerId,
},
},
{
[Modules.STOCK_LOCATION]: {
stock_location_id: location.id,
},
[Modules.FULFILLMENT]: {
fulfillment_provider_id: providerIdCalculated,
},
},
])
@@ -160,14 +197,29 @@ export async function prepareDataFixtures({ container }) {
],
}
const shippingOptionCalculatedData: FulfillmentWorkflow.CreateShippingOptionsWorkflowInput =
{
name: "Calculated shipping option",
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
provider_id: providerIdCalculated,
price_type: "calculated",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
rules: [],
}
const { result } = await createShippingOptionsWorkflow(container).run({
input: [shippingOptionData],
input: [shippingOptionData, shippingOptionCalculatedData],
})
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "shipping_option",
variables: {
id: result[0].id,
id: result.map((r) => r.id),
},
fields: [
"id",
@@ -189,13 +241,17 @@ export async function prepareDataFixtures({ container }) {
const remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const [createdShippingOption] = await remoteQuery(remoteQueryObject)
const shippingOptions = await remoteQuery(remoteQueryObject)
return {
shippingOption: createdShippingOption,
shippingOption: shippingOptions.find((s) => s.price_type === "flat"),
shippingOptionCalculated: shippingOptions.find(
(s) => s.price_type === "calculated"
),
region,
salesChannel,
location,
product,
customer,
inventoryItem,
}
}
@@ -205,18 +261,31 @@ export async function createOrderFixture({
product,
location,
inventoryItem,
region,
salesChannel,
customer,
overrides,
}: {
container: any
product: any
location: any
inventoryItem: any
salesChannel?: any
customer?: any
region?: any
overrides?: { quantity?: number }
}) {
const orderService: IOrderModuleService = container.resolve(Modules.ORDER)
let order = await orderService.createOrders({
region_id: "test_region_id",
email: "foo@bar.com",
region_id: region?.id || "test_region_id",
email: customer?.email || "foo@bar.com",
items: [
{
title: "Custom Item 2",
variant_sku: product.variants[0].sku,
variant_title: product.variants[0].title,
quantity: 1,
quantity: overrides?.quantity ?? 1,
unit_price: 50,
adjustments: [
{
@@ -235,7 +304,7 @@ export async function createOrderFixture({
currency_code: "usd",
},
],
sales_channel_id: "test",
sales_channel_id: salesChannel?.id || "test",
shipping_address: {
first_name: "Test",
last_name: "Test",
@@ -277,7 +346,7 @@ export async function createOrderFixture({
},
],
currency_code: "usd",
customer_id: "joe",
customer_id: customer?.id || "joe",
})
const inventoryModule = container.resolve(Modules.INVENTORY)

View File

@@ -0,0 +1,212 @@
import {
beginClaimOrderWorkflow,
createClaimShippingMethodWorkflow,
createOrderFulfillmentWorkflow,
orderClaimAddNewItemWorkflow,
orderClaimRequestItemReturnWorkflow,
updateClaimAddItemWorkflow,
} from "@medusajs/core-flows"
import { IFulfillmentModuleService, OrderDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
Modules,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { createOrderFixture, prepareDataFixtures } from "../__fixtures__"
jest.setTimeout(50000)
medusaIntegrationTestRunner({
env: { MEDUSA_FF_MEDUSA_V2: true },
testSuite: ({ getContainer }) => {
let container
beforeAll(() => {
container = getContainer()
})
describe("Order change: Claim shipping", () => {
let order: OrderDTO
let service: IFulfillmentModuleService
let fixtures
let claimOrder: OrderDTO
beforeEach(async () => {
fixtures = await prepareDataFixtures({ container })
order = await createOrderFixture({
container,
product: fixtures.product,
location: fixtures.location,
inventoryItem: fixtures.inventoryItem,
salesChannel: fixtures.salesChannel,
customer: fixtures.customer,
region: fixtures.region,
overrides: { quantity: 2 },
})
await createOrderFulfillmentWorkflow(container).run({
input: {
order_id: order.id,
items: [
{
quantity: 2,
id: order.items![0].id,
},
],
},
})
await beginClaimOrderWorkflow(container).run({
input: { order_id: order.id, type: "replace" },
throwOnError: true,
})
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "order_claim",
variables: { order_id: order.id },
fields: ["order_id", "id", "status", "order_change_id"],
})
service = container.resolve(Modules.FULFILLMENT)
;[claimOrder] = await remoteQuery(remoteQueryObject)
})
describe("createClaimShippingMethodWorkflow", () => {
it("should successfully add caluclated inbound and outbound shipping to order changes", async () => {
const { result } = await orderClaimAddNewItemWorkflow(container).run({
input: {
claim_id: claimOrder.id,
items: [
{
variant_id: fixtures.product.variants[0].id,
quantity: 1,
internal_note: "test",
},
],
},
})
const shippingOptionId = fixtures.shippingOptionCalculated.id
const { result: orderChangePreview } =
await createClaimShippingMethodWorkflow(container).run({
input: {
claim_id: claimOrder.id,
shipping_option_id: shippingOptionId,
},
})
// Original shipping + outbound
expect(orderChangePreview.shipping_methods).toHaveLength(2)
const outboundShippingMethod =
orderChangePreview.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
expect((outboundShippingMethod as any).actions).toEqual([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "2.5", precision: 20 },
return_id: null,
claim_id: claimOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 2.5,
}),
])
const { result: orderChangePreview2 } =
await orderClaimRequestItemReturnWorkflow.run({
container,
input: {
claim_id: claimOrder.id,
items: [
{
id: result.items[0].id,
quantity: 1,
},
],
},
})
const associatedReturnId = orderChangePreview2.order_change.return_id
const { result: orderChangePreview3 } =
await createClaimShippingMethodWorkflow(container).run({
input: {
claim_id: claimOrder.id,
return_id: associatedReturnId,
shipping_option_id: shippingOptionId,
},
})
expect(orderChangePreview3.shipping_methods).toHaveLength(3)
const inboundShippingMethod =
orderChangePreview3.shipping_methods?.find(
(sm) =>
sm.shipping_option_id === shippingOptionId &&
sm.actions?.find(
(a) =>
a.action === "SHIPPING_ADD" &&
a.return_id === associatedReturnId
)
)
expect(inboundShippingMethod!.actions![0]).toEqual(
expect.objectContaining({
return_id: associatedReturnId,
claim_id: claimOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 2,
})
)
// Update outbound quantity to test refresh caluclation
const { result: orderChangePreview4 } =
await updateClaimAddItemWorkflow(container).run({
input: {
claim_id: claimOrder.id,
action_id: result.items.find(
(i) => i.variant_id === fixtures.product.variants[0].id
)?.actions?.[0]?.id as string,
data: {
quantity: 2,
},
},
})
const outboundShippingMethod2 =
orderChangePreview4.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
expect((outboundShippingMethod2 as any).actions).toEqual([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "5", precision: 20 },
return_id: null,
claim_id: claimOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 5,
}),
])
})
})
})
},
})

View File

@@ -0,0 +1,214 @@
import {
beginExchangeOrderWorkflow,
createExchangeShippingMethodWorkflow,
createOrderFulfillmentWorkflow,
orderExchangeAddNewItemWorkflow,
orderExchangeRequestItemReturnWorkflow,
updateExchangeAddItemWorkflow,
} from "@medusajs/core-flows"
import { IFulfillmentModuleService, OrderDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
Modules,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { createOrderFixture, prepareDataFixtures } from "../__fixtures__"
jest.setTimeout(50000)
medusaIntegrationTestRunner({
env: { MEDUSA_FF_MEDUSA_V2: true },
testSuite: ({ getContainer }) => {
let container
beforeAll(() => {
container = getContainer()
})
describe("Order change: Exchange shipping", () => {
let order: OrderDTO
let service: IFulfillmentModuleService
let fixtures
let exchangeOrder: OrderDTO
beforeEach(async () => {
fixtures = await prepareDataFixtures({ container })
order = await createOrderFixture({
container,
product: fixtures.product,
location: fixtures.location,
inventoryItem: fixtures.inventoryItem,
salesChannel: fixtures.salesChannel,
customer: fixtures.customer,
region: fixtures.region,
overrides: { quantity: 2 },
})
await createOrderFulfillmentWorkflow(container).run({
input: {
order_id: order.id,
items: [
{
quantity: 2,
id: order.items![0].id,
},
],
},
})
await beginExchangeOrderWorkflow(container).run({
input: { order_id: order.id },
throwOnError: true,
})
const remoteQuery = container.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const remoteQueryObject = remoteQueryObjectFromString({
entryPoint: "order_exchange",
variables: { order_id: order.id },
fields: ["order_id", "id", "status", "order_change_id"],
})
service = container.resolve(Modules.FULFILLMENT)
;[exchangeOrder] = await remoteQuery(remoteQueryObject)
})
describe("createExchangeShippingMethodWorkflow", () => {
it("should successfully add caluclated inbound and outbound shipping to order changes", async () => {
const { result } = await orderExchangeAddNewItemWorkflow(
container
).run({
input: {
exchange_id: exchangeOrder.id,
items: [
{
variant_id: fixtures.product.variants[0].id,
quantity: 1,
internal_note: "test",
},
],
},
})
const shippingOptionId = fixtures.shippingOptionCalculated.id
const { result: orderChangePreview } =
await createExchangeShippingMethodWorkflow(container).run({
input: {
exchange_id: exchangeOrder.id,
shipping_option_id: shippingOptionId,
},
})
// Original shipping + outbound
expect(orderChangePreview.shipping_methods).toHaveLength(2)
const outboundShippingMethod =
orderChangePreview.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
expect((outboundShippingMethod as any).actions).toEqual([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "2.5", precision: 20 },
return_id: null,
exchange_id: exchangeOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 2.5,
}),
])
const { result: orderChangePreview2 } =
await orderExchangeRequestItemReturnWorkflow.run({
container,
input: {
exchange_id: exchangeOrder.id,
items: [
{
id: result.items[0].id,
quantity: 1,
},
],
},
})
const associatedReturnId = orderChangePreview2.order_change.return_id
const { result: orderChangePreview3 } =
await createExchangeShippingMethodWorkflow(container).run({
input: {
exchange_id: exchangeOrder.id,
return_id: associatedReturnId,
shipping_option_id: shippingOptionId,
},
})
expect(orderChangePreview3.shipping_methods).toHaveLength(3)
const inboundShippingMethod =
orderChangePreview3.shipping_methods?.find(
(sm) =>
sm.shipping_option_id === shippingOptionId &&
sm.actions?.find(
(a) =>
a.action === "SHIPPING_ADD" &&
a.return_id === associatedReturnId
)
)
expect(inboundShippingMethod!.actions![0]).toEqual(
expect.objectContaining({
return_id: associatedReturnId,
exchange_id: exchangeOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 2,
})
)
// Update outbound quantity to test refresh caluclation
const { result: orderChangePreview4 } =
await updateExchangeAddItemWorkflow(container).run({
input: {
exchange_id: exchangeOrder.id,
action_id: result.items.find(
(i) => i.variant_id === fixtures.product.variants[0].id
)?.actions?.[0]?.id as string,
data: {
quantity: 2,
},
},
})
const outboundShippingMethod2 =
orderChangePreview4.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
expect((outboundShippingMethod2 as any).actions).toEqual([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "5", precision: 20 },
return_id: null,
exchange_id: exchangeOrder.id,
applied: false,
action: "SHIPPING_ADD",
amount: 5,
}),
])
})
})
})
},
})

View File

@@ -1,6 +1,9 @@
import {
beginReturnOrderWorkflow,
createOrderFulfillmentWorkflow,
createReturnShippingMethodWorkflow,
requestItemReturnWorkflow,
updateRequestItemReturnWorkflow,
} from "@medusajs/core-flows"
import { IFulfillmentModuleService, OrderDTO, ReturnDTO } from "@medusajs/types"
import {
@@ -10,7 +13,6 @@ import {
} from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { createOrderFixture, prepareDataFixtures } from "../__fixtures__"
jest.setTimeout(50000)
medusaIntegrationTestRunner({
@@ -37,6 +39,19 @@ medusaIntegrationTestRunner({
product: fixtures.product,
location: fixtures.location,
inventoryItem: fixtures.inventoryItem,
overrides: { quantity: 2 },
})
await createOrderFulfillmentWorkflow(container).run({
input: {
order_id: order.id,
items: [
{
quantity: 2,
id: order.items![0].id,
},
],
},
})
await beginReturnOrderWorkflow(container).run({
@@ -115,6 +130,121 @@ medusaIntegrationTestRunner({
}),
])
})
it("should successfully add calculated return shipping to order changes", async () => {
const shippingOptionId = fixtures.shippingOptionCalculated.id
const { result: orderChangePreview } =
await createReturnShippingMethodWorkflow(container).run({
input: {
return_id: returnOrder.id,
shipping_option_id: shippingOptionId,
},
})
const shippingMethod = orderChangePreview.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
/**
* Shipping is 0 because the shipping option is calculated based on the return items
* and currently there are no return items.
*/
expect((shippingMethod as any).actions).toEqual([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "0", precision: 20 },
applied: false,
action: "SHIPPING_ADD",
amount: 0,
}),
])
const { result } = await requestItemReturnWorkflow(container).run({
input: {
return_id: returnOrder.id,
items: [
{
id: order.items![0].id,
quantity: 1,
internal_note: "test",
},
],
},
})
console.log(result.items[0].actions)
let updatedShippingMethod = result.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
/**
* Caluclated shipping is 2$ per return item.
*/
expect(updatedShippingMethod).toEqual(
expect.objectContaining({
id: expect.any(String),
shipping_option_id: shippingOptionId,
amount: 2,
actions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "2", precision: 20 },
applied: false,
action: "SHIPPING_ADD",
amount: 2,
}),
]),
})
)
/**
* Update the return item quantity to 2.
*/
const { result: updatedResult } =
await updateRequestItemReturnWorkflow(container).run({
input: {
return_id: returnOrder.id,
action_id: result.items
.find((i) =>
i.actions?.find((a) => a.action === "RETURN_ITEM")
)
?.actions?.find((a) => a.action === "RETURN_ITEM")?.id!,
data: {
quantity: 2,
},
},
})
updatedShippingMethod = updatedResult.shipping_methods?.find(
(sm) => sm.shipping_option_id === shippingOptionId
)
expect(updatedShippingMethod).toEqual(
expect.objectContaining({
id: expect.any(String),
shipping_option_id: shippingOptionId,
amount: 4,
actions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
reference: "order_shipping_method",
reference_id: expect.any(String),
raw_amount: { value: "4", precision: 20 },
applied: false,
action: "SHIPPING_ADD",
amount: 4,
}),
]),
})
)
})
})
})
},

View File

@@ -22,6 +22,12 @@ const customFulfillmentProvider = {
id: "test-provider",
}
const customFulfillmentProviderCalculated = {
resolve: require("./dist/utils/providers/fulfillment-manual-calculated")
.default,
id: "test-provider-calculated",
}
module.exports = {
admin: {
disable: true,
@@ -96,7 +102,10 @@ module.exports = {
[Modules.FULFILLMENT]: {
/** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */
options: {
providers: [customFulfillmentProvider],
providers: [
customFulfillmentProvider,
customFulfillmentProviderCalculated,
],
},
},
[Modules.NOTIFICATION]: {

View File

@@ -0,0 +1,8 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import { ManualFulfillmentService } from "./services/manual-fulfillment"
const services = [ManualFulfillmentService]
export default ModuleProvider(Modules.FULFILLMENT, {
services,
})

View File

@@ -0,0 +1,80 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"
export class ManualFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "manual-calculated"
constructor() {
super()
}
async getFulfillmentOptions() {
return [
{
id: "manual-fulfillment-calculated",
},
{
id: "manual-fulfillment-return-calculated",
is_return: true,
},
]
}
async validateFulfillmentData(optionData, data, context) {
return data
}
async calculatePrice(optionData, data, context) {
if (context.exchange_id) {
return {
calculated_amount:
context.exchange_items.reduce((acc, i) => acc + i.quantity, 0) * 2.5, // mock return cost as 2 per item
is_calculated_price_tax_inclusive: false,
}
}
if (context.claim_id) {
return {
calculated_amount:
context.claim_items.reduce((acc, i) => acc + i.quantity, 0) * 2.5, // mock return cost as 2 per item
is_calculated_price_tax_inclusive: false,
}
}
if (context.return_id) {
return {
calculated_amount:
context.return_items.reduce((acc, i) => acc + i.quantity, 0) * 2, // mock return cost as 2 per item
is_calculated_price_tax_inclusive: false,
}
}
return {
calculated_amount:
context.items.reduce((acc, i) => acc + i.quantity, 0) * 1.5, // mock caluclation as 1.5 per item
is_calculated_price_tax_inclusive: false,
}
}
async canCalculate() {
return true
}
async validateOption(data) {
return true
}
async createFulfillment() {
// No data is being sent anywhere
return {
data: {},
labels: [],
}
}
async cancelFulfillment() {
return {}
}
async createReturnFulfillment() {
return { data: {}, labels: [] }
}
}