fix: Add shipping method data validation (#9542)
* fix: Add shipping method data validation * fix: return type
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
addShippingMethodToWorkflow,
|
||||
addShippingMethodToCartWorkflow,
|
||||
addToCartWorkflow,
|
||||
createCartWorkflow,
|
||||
createPaymentCollectionForCartWorkflow,
|
||||
@@ -1638,22 +1638,7 @@ medusaIntegrationTestRunner({
|
||||
},
|
||||
])
|
||||
|
||||
await addShippingMethodToWorkflow(appContainer).run({
|
||||
input: {
|
||||
options: [{ id: shippingOption.id }],
|
||||
cart_id: cart.id,
|
||||
},
|
||||
})
|
||||
|
||||
await addShippingMethodToWorkflow(appContainer).run({
|
||||
input: {
|
||||
options: [{ id: shippingOption.id }],
|
||||
cart_id: cart.id,
|
||||
},
|
||||
})
|
||||
|
||||
// should remove the previous shipping method
|
||||
await addShippingMethodToWorkflow(appContainer).run({
|
||||
await addShippingMethodToCartWorkflow(appContainer).run({
|
||||
input: {
|
||||
options: [{ id: shippingOption.id }],
|
||||
cart_id: cart.id,
|
||||
@@ -1707,7 +1692,7 @@ medusaIntegrationTestRunner({
|
||||
},
|
||||
])
|
||||
|
||||
const { errors } = await addShippingMethodToWorkflow(
|
||||
const { errors } = await addShippingMethodToCartWorkflow(
|
||||
appContainer
|
||||
).run({
|
||||
input: {
|
||||
@@ -1729,7 +1714,7 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
|
||||
it("should throw error when shipping option is not present in the db", async () => {
|
||||
const { errors } = await addShippingMethodToWorkflow(
|
||||
const { errors } = await addShippingMethodToCartWorkflow(
|
||||
appContainer
|
||||
).run({
|
||||
input: {
|
||||
@@ -1749,6 +1734,103 @@ medusaIntegrationTestRunner({
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should add shipping method with custom data", async () => {
|
||||
const stockLocation = (
|
||||
await api.post(
|
||||
`/admin/stock-locations`,
|
||||
{ name: "test location" },
|
||||
adminHeaders
|
||||
)
|
||||
).data.stock_location
|
||||
|
||||
await remoteLink.create([
|
||||
{
|
||||
[Modules.STOCK_LOCATION]: {
|
||||
stock_location_id: stockLocation.id,
|
||||
},
|
||||
[Modules.FULFILLMENT]: {
|
||||
fulfillment_set_id: fulfillmentSet.id,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
await api.post(
|
||||
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
|
||||
{ add: ["manual_test-provider"] },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const shippingOption = await fulfillmentModule.createShippingOptions({
|
||||
name: "Test shipping option",
|
||||
service_zone_id: fulfillmentSet.service_zones[0].id,
|
||||
shipping_profile_id: shippingProfile.id,
|
||||
provider_id: "manual_test-provider",
|
||||
price_type: "flat",
|
||||
type: {
|
||||
label: "Test type",
|
||||
description: "Test description",
|
||||
code: "test-code",
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
operator: RuleOperator.EQ,
|
||||
attribute: "is_return",
|
||||
value: "false",
|
||||
},
|
||||
{
|
||||
operator: RuleOperator.EQ,
|
||||
attribute: "enabled_in_store",
|
||||
value: "true",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await remoteLink.create([
|
||||
{
|
||||
[Modules.FULFILLMENT]: { shipping_option_id: shippingOption.id },
|
||||
[Modules.PRICING]: { price_set_id: priceSet.id },
|
||||
},
|
||||
])
|
||||
|
||||
await addShippingMethodToCartWorkflow(appContainer).run({
|
||||
input: {
|
||||
options: [{ id: shippingOption.id, data: { test: "test" } }],
|
||||
cart_id: cart.id,
|
||||
},
|
||||
})
|
||||
|
||||
cart = await cartModuleService.retrieveCart(cart.id, {
|
||||
select: ["id"],
|
||||
relations: ["shipping_methods"],
|
||||
})
|
||||
|
||||
expect(cart).toEqual(
|
||||
expect.objectContaining({
|
||||
id: cart.id,
|
||||
shipping_methods: [
|
||||
{
|
||||
id: expect.any(String),
|
||||
cart_id: cart.id,
|
||||
description: null,
|
||||
amount: 3000,
|
||||
raw_amount: {
|
||||
value: "3000",
|
||||
precision: 20,
|
||||
},
|
||||
metadata: null,
|
||||
is_tax_inclusive: true,
|
||||
name: "Test shipping option",
|
||||
data: { test: "test" },
|
||||
shipping_option_id: shippingOption.id,
|
||||
deleted_at: null,
|
||||
updated_at: expect.any(Date),
|
||||
created_at: expect.any(Date),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listShippingOptionsForCartWorkflow", () => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Modules, promiseAll } from "@medusajs/framework/utils"
|
||||
import { IFulfillmentModuleService } from "@medusajs/types"
|
||||
import { createStep, StepResponse } from "@medusajs/workflows-sdk"
|
||||
|
||||
export interface ValidateShippingMethodsDataInput {
|
||||
context: Record<string, unknown>
|
||||
options_to_validate: {
|
||||
id: string
|
||||
provider_id: string
|
||||
option_data: Record<string, unknown>
|
||||
method_data: Record<string, unknown>
|
||||
}[]
|
||||
}
|
||||
|
||||
export const validateAndReturnShippingMethodsDataStepId =
|
||||
"validate-and-return-shipping-methods-data"
|
||||
/**
|
||||
* This step validates shipping options to ensure they can be applied on a cart.
|
||||
*/
|
||||
export const validateAndReturnShippingMethodsDataStep = createStep(
|
||||
validateAndReturnShippingMethodsDataStepId,
|
||||
async (data: ValidateShippingMethodsDataInput, { container }) => {
|
||||
const { options_to_validate = [] } = data
|
||||
|
||||
if (!options_to_validate.length) {
|
||||
return new StepResponse(void 0)
|
||||
}
|
||||
|
||||
const fulfillmentModule = container.resolve<IFulfillmentModuleService>(
|
||||
Modules.FULFILLMENT
|
||||
)
|
||||
|
||||
const validatedData = await promiseAll(
|
||||
options_to_validate.map(async (option) => {
|
||||
const validated = await fulfillmentModule.validateFulfillmentData(
|
||||
option.provider_id,
|
||||
option.option_data,
|
||||
option.method_data,
|
||||
data.context
|
||||
)
|
||||
|
||||
return {
|
||||
[option.id]: validated,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return new StepResponse(validatedData)
|
||||
}
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validateCartShippingOptionsStep,
|
||||
} from "../steps"
|
||||
import { validateCartStep } from "../steps/validate-cart"
|
||||
import { validateAndReturnShippingMethodsDataStep } from "../steps/validate-shipping-methods-data"
|
||||
import { cartFieldsForRefreshSteps } from "../utils/fields"
|
||||
import { updateCartPromotionsWorkflow } from "./update-cart-promotions"
|
||||
import { updateTaxLinesWorkflow } from "./update-tax-lines"
|
||||
@@ -28,7 +29,7 @@ export const addShippingMethodToCartWorkflowId = "add-shipping-method-to-cart"
|
||||
/**
|
||||
* This workflow adds shipping methods to a cart.
|
||||
*/
|
||||
export const addShippingMethodToWorkflow = createWorkflow(
|
||||
export const addShippingMethodToCartWorkflow = createWorkflow(
|
||||
addShippingMethodToCartWorkflowId,
|
||||
(
|
||||
input: WorkflowData<AddShippingMethodToCartWorkflowInput>
|
||||
@@ -59,6 +60,7 @@ export const addShippingMethodToWorkflow = createWorkflow(
|
||||
"name",
|
||||
"calculated_price.calculated_amount",
|
||||
"calculated_price.is_calculated_price_tax_inclusive",
|
||||
"provider_id",
|
||||
],
|
||||
variables: {
|
||||
id: optionIds,
|
||||
@@ -68,8 +70,30 @@ export const addShippingMethodToWorkflow = createWorkflow(
|
||||
},
|
||||
}).config({ name: "fetch-shipping-option" })
|
||||
|
||||
const shippingMethodInput = transform(
|
||||
const validateShippingMethodsDataInput = transform(
|
||||
{ input, shippingOptions },
|
||||
(data) => {
|
||||
return data.input.options.map((inputOption) => {
|
||||
const shippingOption = data.shippingOptions.find(
|
||||
(so) => so.id === inputOption.id
|
||||
)
|
||||
return {
|
||||
id: inputOption.id,
|
||||
provider_id: shippingOption?.provider_id,
|
||||
option_data: shippingOption?.data ?? {},
|
||||
method_data: inputOption.data ?? {},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const validatedMethodData = validateAndReturnShippingMethodsDataStep({
|
||||
options_to_validate: validateShippingMethodsDataInput,
|
||||
context: {}, // TODO: Add cart, when we have a better idea about what's appropriate to pass
|
||||
})
|
||||
|
||||
const shippingMethodInput = transform(
|
||||
{ input, shippingOptions, validatedMethodData },
|
||||
(data) => {
|
||||
const options = (data.input.options ?? []).map((option) => {
|
||||
const shippingOption = data.shippingOptions.find(
|
||||
@@ -83,13 +107,17 @@ export const addShippingMethodToWorkflow = createWorkflow(
|
||||
)
|
||||
}
|
||||
|
||||
const methodData = data.validatedMethodData?.find((methodData) => {
|
||||
return methodData?.[option.id]
|
||||
})
|
||||
|
||||
return {
|
||||
shipping_option_id: shippingOption.id,
|
||||
amount: shippingOption.calculated_price.calculated_amount,
|
||||
is_tax_inclusive:
|
||||
!!shippingOption.calculated_price
|
||||
.is_calculated_price_tax_inclusive,
|
||||
data: option.data ?? {},
|
||||
data: methodData?.[option.id] ?? {},
|
||||
name: shippingOption.name,
|
||||
cart_id: data.input.cart_id,
|
||||
}
|
||||
|
||||
@@ -2572,6 +2572,38 @@ export interface IFulfillmentModuleService extends IModuleService {
|
||||
data: Record<string, unknown>
|
||||
): Promise<boolean>
|
||||
|
||||
/**
|
||||
* This method validates fulfillment data with the provider it belongs to.
|
||||
* e.g. if the shipping option requires a drop point, the data you pass to create the
|
||||
* shipping method must contain a drop point ID. This method can be used to
|
||||
* validate that.
|
||||
*
|
||||
* @param {string} providerId - The fulfillment provider's ID.
|
||||
* @param {Record<string, unknown>} optionData - The fulfillment option data to validate.
|
||||
* @param {Record<string, unknown>} data - The fulfillment data to validate.
|
||||
* @param {Record<string, unknown>} context - The context to validate the fulfillment option data in.
|
||||
* @returns {Promise<boolean>} Whether the fulfillment option data is valid with the specified provider.
|
||||
*
|
||||
* @example
|
||||
* const isValid =
|
||||
* await fulfillmentModuleService.validateFulfillmentData(
|
||||
* "webshipper",
|
||||
* {
|
||||
* requires_drop_point: true,
|
||||
* },
|
||||
* {
|
||||
* drop_point_id: "dp_123",
|
||||
* },
|
||||
* {}
|
||||
* )
|
||||
*/
|
||||
validateFulfillmentData(
|
||||
providerId: string,
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>>
|
||||
|
||||
/**
|
||||
* This method checks whether a shipping option can be used for a specified context.
|
||||
*
|
||||
|
||||
@@ -79,14 +79,6 @@ export class AbstractFulfillmentProviderService
|
||||
return obj?.constructor?._isFulfillmentService
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @privateRemarks
|
||||
* This method is ignored as {@link validateOption} is the one used by the Fulfillment Module.
|
||||
*/
|
||||
static validateOptions(options: Record<any, any>): void | never {}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
@@ -154,7 +146,11 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async validateFulfillmentData(optionData, data, context): Promise<any> {
|
||||
async validateFulfillmentData(
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
throw Error("validateFulfillmentData must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -175,7 +171,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async validateOption(data): Promise<boolean> {
|
||||
async validateOption(data: Record<string, unknown>): Promise<boolean> {
|
||||
throw Error("validateOption must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -194,7 +190,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async canCalculate(data): Promise<boolean> {
|
||||
async canCalculate(data: Record<string, unknown>): Promise<boolean> {
|
||||
throw Error("canCalculate must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -221,7 +217,11 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async calculatePrice(optionData, data, cart): Promise<number> {
|
||||
async calculatePrice(
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
throw Error("calculatePrice must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -265,7 +265,12 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async createFulfillment(data, items, order, fulfillment): Promise<any> {
|
||||
async createFulfillment(
|
||||
data: object,
|
||||
items: object[],
|
||||
order: object | undefined,
|
||||
fulfillment: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
throw Error("createFulfillment must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -285,7 +290,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async cancelFulfillment(fulfillment): Promise<any> {
|
||||
async cancelFulfillment(fulfillment: Record<string, unknown>): Promise<any> {
|
||||
throw Error("cancelFulfillment must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -305,7 +310,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async getFulfillmentDocuments(data) {
|
||||
async getFulfillmentDocuments(data: Record<string, unknown>) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -340,7 +345,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async createReturnFulfillment(fulfillment): Promise<any> {
|
||||
async createReturnFulfillment(fulfillment: Record<string, unknown>): Promise<any> {
|
||||
throw Error("createReturn must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -360,7 +365,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async getReturnDocuments(data) {
|
||||
async getReturnDocuments(data: Record<string, unknown>) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -381,7 +386,7 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
*
|
||||
*/
|
||||
async getShipmentDocuments(data) {
|
||||
async getShipmentDocuments(data: Record<string, unknown>) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -408,7 +413,10 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async retrieveDocuments(fulfillmentData, documentType) {
|
||||
async retrieveDocuments(
|
||||
fulfillmentData: Record<string, unknown>,
|
||||
documentType: string
|
||||
) {
|
||||
throw Error("retrieveDocuments must be overridden by the child class")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { addShippingMethodToWorkflow } from "@medusajs/core-flows"
|
||||
import { addShippingMethodToCartWorkflow } from "@medusajs/core-flows"
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { HttpTypes } from "@medusajs/framework/types"
|
||||
import { refetchCart } from "../../helpers"
|
||||
import { StoreAddCartShippingMethodsType } from "../../validators"
|
||||
import { HttpTypes } from "@medusajs/framework/types"
|
||||
|
||||
export const POST = async (
|
||||
req: MedusaRequest<StoreAddCartShippingMethodsType>,
|
||||
res: MedusaResponse<HttpTypes.StoreCartResponse>
|
||||
) => {
|
||||
const workflow = addShippingMethodToWorkflow(req.scope)
|
||||
const payload = req.validatedBody
|
||||
|
||||
await workflow.run({
|
||||
await addShippingMethodToCartWorkflow(req.scope).run({
|
||||
input: {
|
||||
options: [{ id: payload.option_id, data: payload.data }],
|
||||
cart_id: req.params.id,
|
||||
|
||||
@@ -1927,6 +1927,20 @@ export default class FulfillmentModuleService
|
||||
)
|
||||
}
|
||||
|
||||
async validateFulfillmentData(
|
||||
providerId: string,
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await this.fulfillmentProviderService_.validateFulfillmentData(
|
||||
providerId,
|
||||
optionData,
|
||||
data,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
async validateFulfillmentOption(
|
||||
providerId: string,
|
||||
data: Record<string, unknown>
|
||||
|
||||
Reference in New Issue
Block a user