From c4fde7ea5ce3e4f8873b4b6e3f90653b79cfbb18 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 21 May 2024 13:48:59 +0200 Subject: [PATCH] feat(core-flows, fulfillment): Add create return specific method and add more tests (#7357) * feat(core-flows, fulfillment): Add create return specific method and add more tests * fix defautl providers in tests fixtures * more tests * wip fixes * fix flow and tests * cleanup --- .../order/workflows/create-return.spec.ts | 101 +++++++++++++++- .../src/order/workflows/create-return.ts | 72 +++++++---- .../core/types/src/fulfillment/service.ts | 41 ++++++- .../providers/default-provider.ts | 4 + .../fulfillment.spec.ts | 57 +++++++++ .../services/fulfillment-module-service.ts | 113 +++++++++--------- .../src/services/fulfillment-provider.ts | 8 ++ packages/modules/order/src/joiner-config.ts | 3 +- .../src/services/manual-fulfillment.ts | 10 +- 9 files changed, 318 insertions(+), 91 deletions(-) diff --git a/integration-tests/modules/__tests__/order/workflows/create-return.spec.ts b/integration-tests/modules/__tests__/order/workflows/create-return.spec.ts index 858f801626..db83bc3cc0 100644 --- a/integration-tests/modules/__tests__/order/workflows/create-return.spec.ts +++ b/integration-tests/modules/__tests__/order/workflows/create-return.spec.ts @@ -1,5 +1,10 @@ -import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk" import { + ModuleRegistrationName, + Modules, + RemoteLink, +} from "@medusajs/modules-sdk" +import { + FulfillmentSetDTO, FulfillmentWorkflow, IOrderModuleService, IRegionModuleService, @@ -208,6 +213,7 @@ async function prepareDataFixtures({ container }) { salesChannel, location, product, + fulfillmentSet, } } @@ -312,6 +318,17 @@ async function createOrderFixture({ container, product }) { }, ]) + const returnReason = await orderService.createReturnReasons({ + value: "Test reason", + label: "Test reason", + }) + + await orderService.createReturnReasons({ + value: "Test child reason", + label: "Test child reason", + parent_return_reason_id: returnReason.id, + }) + await orderService.applyPendingOrderActions(order.id) order = await orderService.retrieve(order.id, { @@ -335,6 +352,7 @@ medusaIntegrationTestRunner({ let region: RegionDTO let location: StockLocationDTO let product: ProductDTO + let fulfillmentSet: FulfillmentSetDTO let orderService: IOrderModuleService @@ -347,12 +365,18 @@ medusaIntegrationTestRunner({ region = fixtures.region location = fixtures.location product = fixtures.product + fulfillmentSet = fixtures.fulfillmentSet orderService = container.resolve(ModuleRegistrationName.ORDER) }) it("should create a return order", async () => { const order = await createOrderFixture({ container, product }) + const reasons = await orderService.listReturnReasons({}) + const testReason = reasons.find( + (r) => r.value.toLowerCase() === "test child reason" + )! + const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput = { order_id: order.id, @@ -363,6 +387,7 @@ medusaIntegrationTestRunner({ { id: order.items![0].id, quantity: 1, + reason_id: testReason.id, }, ], } @@ -468,6 +493,80 @@ medusaIntegrationTestRunner({ }) ) }) + + it("should fail when location is not linked", async () => { + const order = await createOrderFixture({ container, product }) + const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput = + { + order_id: order.id, + return_shipping: { + option_id: shippingOption.id, + }, + items: [ + { + id: order.items![0].id, + quantity: 1, + }, + ], + } + + // Remove the location link + const remoteLink = container.resolve( + ContainerRegistrationKeys.REMOTE_LINK + ) as RemoteLink + + await remoteLink.dismiss([ + { + [Modules.STOCK_LOCATION]: { + stock_location_id: location.id, + }, + [Modules.FULFILLMENT]: { + fulfillment_set_id: fulfillmentSet.id, + }, + }, + ]) + + const { errors } = await createReturnOrderWorkflow(container).run({ + input: createReturnOrderData, + throwOnError: false, + }) + + await expect(errors[0].error.message).toBe( + `Cannot create return without stock location, either provide a location or you should link the shipping option ${shippingOption.id} to a stock location.` + ) + }) + + it("should fail when a reason with children is provided", async () => { + const order = await createOrderFixture({ container, product }) + const reasons = await orderService.listReturnReasons({}) + const testReason = reasons.find( + (r) => r.value.toLowerCase() === "test reason" + )! + + const createReturnOrderData: OrderWorkflow.CreateOrderReturnWorkflowInput = + { + order_id: order.id, + return_shipping: { + option_id: shippingOption.id, + }, + items: [ + { + id: order.items![0].id, + quantity: 1, + reason_id: testReason.id, + }, + ], + } + + const { errors } = await createReturnOrderWorkflow(container).run({ + input: createReturnOrderData, + throwOnError: false, + }) + + expect(errors[0].error.message).toBe( + `Cannot apply return reason with id ${testReason.id} to order with id ${order.id}. Return reason has nested reasons.` + ) + }) }) }, }) diff --git a/packages/core/core-flows/src/order/workflows/create-return.ts b/packages/core/core-flows/src/order/workflows/create-return.ts index 4563fba76b..1ddf9b2360 100644 --- a/packages/core/core-flows/src/order/workflows/create-return.ts +++ b/packages/core/core-flows/src/order/workflows/create-return.ts @@ -7,6 +7,7 @@ import { WithCalculatedPrice, } from "@medusajs/types" import { + createStep, createWorkflow, transform, WorkflowData, @@ -19,6 +20,7 @@ import { MathBN, MedusaError, Modules, + remoteQueryObjectFromString, } from "@medusajs/utils" import { updateOrderTaxLinesStep } from "../steps" import { createReturnStep } from "../steps/create-return" @@ -55,7 +57,7 @@ function throwIfItemsDoesNotExistsInOrder({ } } -function validateReturnReasons( +async function validateReturnReasons( { orderId, inputItems, @@ -66,24 +68,32 @@ function validateReturnReasons( { container } ) { const reasonIds = inputItems.map((i) => i.reason_id).filter(Boolean) - if (!reasonIds.length) { return } const remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - const returnReasons = remoteQuery({ - entry_point: "return_reasons", - fields: ["return_reason_children.*"], - variables: { id: [inputItems.map((item) => item.reason_id)] }, + const remoteQueryObject = remoteQueryObjectFromString({ + entryPoint: "return_reasons", + fields: [ + "id", + "parent_return_reason_id", + "parent_return_reason", + "return_reason_children.id", + ], + variables: { id: [inputItems.map((item) => item.reason_id)], limit: null }, }) + const returnReasons = await remoteQuery(remoteQueryObject) + const reasons = returnReasons.map((r) => r.id) - const hasInvalidReasons = reasons.filter( - // We do not allow for root reason to be applied - (reason) => reason.return_reason_children.length > 0 - ) + const hasInvalidReasons = returnReasons + .filter( + // We do not allow for root reason to be applied + (reason) => reason.return_reason_children.length > 0 + ) + .map((r) => r.id) const hasNonExistingReasons = arrayDifference(reasonIds, reasons) if (hasNonExistingReasons.length) { @@ -95,7 +105,7 @@ function validateReturnReasons( ) } - if (hasInvalidReasons.length()) { + if (hasInvalidReasons.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Cannot apply return reason with id ${hasInvalidReasons.join( @@ -238,12 +248,34 @@ function prepareReturnShippingOptionQueryVariables({ return variables } +const validationStep = createStep( + "create-return-order-validation", + async function ( + { + order, + input, + }: { + order + input: OrderWorkflow.CreateOrderReturnWorkflowInput + }, + context + ) { + throwIfOrderIsCancelled({ order }) + throwIfItemsDoesNotExistsInOrder({ order, inputItems: input.items }) + await validateReturnReasons( + { orderId: input.order_id, inputItems: input.items }, + context + ) + validateCustomRefundAmount({ order, refundAmount: input.refund_amount }) + } +) + export const createReturnOrderWorkflowId = "create-return-order" export const createReturnOrderWorkflow = createWorkflow( createReturnOrderWorkflowId, - ( + function ( input: WorkflowData - ): WorkflowData => { + ): WorkflowData { const order: OrderDTO = useRemoteQueryStep({ entry_point: "orders", fields: [ @@ -259,19 +291,7 @@ export const createReturnOrderWorkflow = createWorkflow( throw_if_key_not_found: true, }) - transform({ order }, throwIfOrderIsCancelled) - transform( - { order, inputItems: input.items }, - throwIfItemsDoesNotExistsInOrder - ) - transform( - { orderId: input.order_id, inputItems: input.items }, - validateReturnReasons - ) - transform( - { order, refundAmount: input.refund_amount }, - validateCustomRefundAmount - ) + validationStep({ order, input }) const returnShippingOptionsVariables = transform( { input, order }, diff --git a/packages/core/types/src/fulfillment/service.ts b/packages/core/types/src/fulfillment/service.ts index 6dbcc144f2..69f450f0c8 100644 --- a/packages/core/types/src/fulfillment/service.ts +++ b/packages/core/types/src/fulfillment/service.ts @@ -2367,7 +2367,7 @@ export interface IFulfillmentModuleService extends IModuleService { ): Promise<[FulfillmentDTO[], number]> /** - * This method creates a fulfillment. + * This method creates a fulfillment and call the provider to create a fulfillment. * * @param {CreateFulfillmentDTO} data - The fulfillment to be created. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. @@ -2405,6 +2405,45 @@ export interface IFulfillmentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method creates a fulfillment and call the provider to create a return. + * + * @param {CreateFulfillmentDTO} data - The fulfillment to be created. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The created fulfillment. + * + * @example + * const fulfillment = + * await fulfillmentModuleService.createReturnFulfillment({ + * location_id: "loc_123", + * provider_id: "webshipper", + * delivery_address: { + * address_1: "4120 Auto Park Cir", + * country_code: "us", + * }, + * items: [ + * { + * title: "Shirt", + * sku: "SHIRT", + * quantity: 1, + * barcode: "ABCED", + * }, + * ], + * labels: [ + * { + * tracking_number: "1234567", + * tracking_url: "https://example.com/tracking", + * label_url: "https://example.com/label", + * }, + * ], + * order: {}, + * }) + */ + createReturnFulfillment( + data: CreateFulfillmentDTO, + sharedContext?: Context + ): Promise + /** * This method updates an existing fulfillment. * diff --git a/packages/modules/fulfillment/integration-tests/__fixtures__/providers/default-provider.ts b/packages/modules/fulfillment/integration-tests/__fixtures__/providers/default-provider.ts index bf179a8e5e..00bda21149 100644 --- a/packages/modules/fulfillment/integration-tests/__fixtures__/providers/default-provider.ts +++ b/packages/modules/fulfillment/integration-tests/__fixtures__/providers/default-provider.ts @@ -14,6 +14,10 @@ export class FulfillmentProviderServiceFixtures extends AbstractFulfillmentProvi async getFulfillmentOptions(): Promise { return {} } + + async createReturnFulfillment(fulfillment): Promise { + return {} + } } export const services = [FulfillmentProviderServiceFixtures] diff --git a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment.spec.ts b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment.spec.ts index f161d9b975..696caaddad 100644 --- a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment.spec.ts +++ b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment.spec.ts @@ -137,6 +137,63 @@ moduleIntegrationTestRunner({ }) ) }) + + it("should create a return fulfillment", async () => { + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + const fulfillmentSet = await service.create({ + name: "test", + type: "test-type", + }) + const serviceZone = await service.createServiceZones({ + name: "test", + fulfillment_set_id: fulfillmentSet.id, + }) + + const shippingOption = await service.createShippingOptions( + generateCreateShippingOptionsData({ + provider_id: providerId, + service_zone_id: serviceZone.id, + shipping_profile_id: shippingProfile.id, + }) + ) + + const fulfillment = await service.createReturnFulfillment( + generateCreateFulfillmentData({ + provider_id: providerId, + shipping_option_id: shippingOption.id, + }) + ) + + expect(fulfillment).toEqual( + expect.objectContaining({ + id: expect.any(String), + packed_at: null, + shipped_at: null, + delivered_at: null, + canceled_at: null, + data: null, + provider_id: providerId, + shipping_option_id: shippingOption.id, + metadata: null, + delivery_address: expect.objectContaining({ + id: expect.any(String), + }), + items: [ + expect.objectContaining({ + id: expect.any(String), + }), + ], + labels: [ + expect.objectContaining({ + id: expect.any(String), + }), + ], + }) + ) + }) }) describe("on cancel", () => { diff --git a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts index 0aa40c53c9..09367f7072 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts @@ -193,9 +193,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.ShippingOptionDTO[] - >(shippingOptions, { - populate: true, - }) + >(shippingOptions) } @InjectManager("baseRepository_") @@ -211,10 +209,7 @@ export default class FulfillmentModuleService< ) return await this.baseRepository_.serialize( - fulfillment, - { - populate: true, - } + fulfillment ) } @@ -232,9 +227,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.FulfillmentDTO[] - >(fulfillments, { - populate: true, - }) + >(fulfillments) } @InjectManager("baseRepository_") @@ -251,10 +244,7 @@ export default class FulfillmentModuleService< return [ await this.baseRepository_.serialize( - fulfillments, - { - populate: true, - } + fulfillments ), count, ] @@ -283,9 +273,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.FulfillmentSetDTO | FulfillmentTypes.FulfillmentSetDTO[] - >(createdFulfillmentSets, { - populate: true, - }) + >(createdFulfillmentSets) } @InjectTransactionManager("baseRepository_") @@ -351,9 +339,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.ServiceZoneDTO | FulfillmentTypes.ServiceZoneDTO[] - >(createdServiceZones, { - populate: true, - }) + >(createdServiceZones) } @InjectTransactionManager("baseRepository_") @@ -410,9 +396,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[] - >(createdShippingOptions, { - populate: true, - }) + >(createdShippingOptions) } @InjectTransactionManager("baseRepository_") @@ -469,9 +453,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< | FulfillmentTypes.ShippingProfileDTO | FulfillmentTypes.ShippingProfileDTO[] - >(createdShippingProfiles, { - populate: true, - }) + >(createdShippingProfiles) } @InjectTransactionManager("baseRepository_") @@ -523,10 +505,7 @@ export default class FulfillmentModuleService< ) return await this.baseRepository_.serialize( - Array.isArray(data) ? createdGeoZones : createdGeoZones[0], - { - populate: true, - } + Array.isArray(data) ? createdGeoZones : createdGeoZones[0] ) } @@ -557,9 +536,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< | FulfillmentTypes.ShippingOptionRuleDTO | FulfillmentTypes.ShippingOptionRuleDTO[] - >(createdShippingOptionRules, { - populate: true, - }) + >(createdShippingOptionRules) } @InjectTransactionManager("baseRepository_") @@ -627,10 +604,43 @@ export default class FulfillmentModuleService< } return await this.baseRepository_.serialize( - fulfillment, - { - populate: true, - } + fulfillment + ) + } + + @InjectManager("baseRepository_") + async createReturnFulfillment( + data: FulfillmentTypes.CreateFulfillmentDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const { order, ...fulfillmentDataToCreate } = data + + const fulfillment = await this.fulfillmentService_.create( + fulfillmentDataToCreate, + sharedContext + ) + + let fulfillmentThirdPartyData!: any + try { + fulfillmentThirdPartyData = + await this.fulfillmentProviderService_.createReturn( + fulfillment.provider_id, + fulfillment as Record + ) + await this.fulfillmentService_.update( + { + id: fulfillment.id, + data: fulfillmentThirdPartyData ?? {}, + }, + sharedContext + ) + } catch (error) { + await this.fulfillmentService_.delete(fulfillment.id, sharedContext) + throw error + } + + return await this.baseRepository_.serialize( + fulfillment ) } @@ -654,9 +664,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.FulfillmentSetDTO | FulfillmentTypes.FulfillmentSetDTO[] - >(updatedFulfillmentSets, { - populate: true, - }) + >(updatedFulfillmentSets) } @InjectTransactionManager("baseRepository_") @@ -865,9 +873,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< FulfillmentTypes.ServiceZoneDTO | FulfillmentTypes.ServiceZoneDTO[] - >(toReturn, { - populate: true, - }) + >(toReturn) } @InjectTransactionManager("baseRepository_") @@ -1110,9 +1116,7 @@ export default class FulfillmentModuleService< const serialized = await this.baseRepository_.serialize< FulfillmentTypes.ShippingOptionDTO | FulfillmentTypes.ShippingOptionDTO[] - >(updatedShippingOptions, { - populate: true, - }) + >(updatedShippingOptions) return isString(idOrSelector) ? serialized[0] : serialized } @@ -1359,9 +1363,7 @@ export default class FulfillmentModuleService< const serialized = await this.baseRepository_.serialize< FulfillmentTypes.GeoZoneDTO[] - >(updatedGeoZones, { - populate: true, - }) + >(updatedGeoZones) return Array.isArray(data) ? serialized : serialized[0] } @@ -1393,9 +1395,7 @@ export default class FulfillmentModuleService< return await this.baseRepository_.serialize< | FulfillmentTypes.ShippingOptionRuleDTO | FulfillmentTypes.ShippingOptionRuleDTO[] - >(updatedShippingOptionRules, { - populate: true, - }) + >(updatedShippingOptionRules) } @InjectTransactionManager("baseRepository_") @@ -1434,10 +1434,7 @@ export default class FulfillmentModuleService< const serialized = await this.baseRepository_.serialize( - fulfillment, - { - populate: true, - } + fulfillment ) return Array.isArray(serialized) ? serialized[0] : serialized @@ -1478,9 +1475,7 @@ export default class FulfillmentModuleService< ) } - const result = await this.baseRepository_.serialize(fulfillment, { - populate: true, - }) + const result = await this.baseRepository_.serialize(fulfillment) return Array.isArray(result) ? result[0] : result } diff --git a/packages/modules/fulfillment/src/services/fulfillment-provider.ts b/packages/modules/fulfillment/src/services/fulfillment-provider.ts index 2fb1abe798..e314ff9d9c 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-provider.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-provider.ts @@ -101,4 +101,12 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.internal const provider = this.retrieveProviderRegistration(providerId) return await provider.cancelFulfillment(fulfillment) } + + async createReturn( + providerId: string, + fulfillment: Record, + ) { + const provider = this.retrieveProviderRegistration(providerId) + return await provider.createReturnFulfillment(fulfillment) + } } diff --git a/packages/modules/order/src/joiner-config.ts b/packages/modules/order/src/joiner-config.ts index 3d9a455163..d3250a4c6d 100644 --- a/packages/modules/order/src/joiner-config.ts +++ b/packages/modules/order/src/joiner-config.ts @@ -1,6 +1,6 @@ import { Modules } from "@medusajs/modules-sdk" import { ModuleJoinerConfig } from "@medusajs/types" -import { MapToConfig } from "@medusajs/utils" +import { MapToConfig, pluralize } from "@medusajs/utils" import { LineItem, ReturnReason } from "@models" import Order from "./models/order" @@ -37,6 +37,7 @@ export const joinerConfig: ModuleJoinerConfig = { name: ["return_reason", "return_reasons"], args: { entity: ReturnReason.name, + methodSuffix: pluralize(ReturnReason.name), }, }, ], diff --git a/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts b/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts index aee49682a8..835572fa3e 100644 --- a/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts +++ b/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts @@ -29,16 +29,20 @@ export class ManualFulfillmentService extends AbstractFulfillmentProviderService return data } - async validateOption(data: Record): Promise { + async validateOption(data: Record): Promise { return true } - async createFulfillment(): Promise> { + async createFulfillment(): Promise> { // No data is being sent anywhere return {} } - async cancelFulfillment(fulfillment: Record): Promise { + async cancelFulfillment(): Promise { + return {} + } + + async createReturnFulfillment(): Promise { return {} } }