diff --git a/packages/admin/dashboard/src/routes/locations/location-detail/components/location-general-section/location-general-section.tsx b/packages/admin/dashboard/src/routes/locations/location-detail/components/location-general-section/location-general-section.tsx index 8ce5d7d13f7e3..a138571b243a2 100644 --- a/packages/admin/dashboard/src/routes/locations/location-detail/components/location-general-section/location-general-section.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-detail/components/location-general-section/location-general-section.tsx @@ -47,7 +47,10 @@ import { isOptionEnabledInStore, isReturnOption, } from "../../../../../lib/shipping-options" -import { FulfillmentSetType } from "../../../common/constants" +import { + FulfillmentSetType, + ShippingOptionPriceType, +} from "../../../common/constants" type LocationGeneralSectionProps = { location: HttpTypes.AdminStockLocation @@ -167,6 +170,8 @@ function ShippingOption({ { label: t("stockLocations.shippingOptions.pricing.action"), icon: , + disabled: + option.price_type === ShippingOptionPriceType.Calculated, to: `/settings/locations/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/pricing`, }, ], diff --git a/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts b/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts index 9aad02092577f..f58b3babba0f9 100644 --- a/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts +++ b/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts @@ -25,7 +25,7 @@ interface PriceRegionId { export type SetShippingOptionsPricesStepInput = { id: string - prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"] + prices?: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[] }[] async function getCurrentShippingOptionPrices( diff --git a/packages/core/core-flows/src/fulfillment/steps/validate-shipping-option-prices.ts b/packages/core/core-flows/src/fulfillment/steps/validate-shipping-option-prices.ts index ab07f9280693c..e6dc50d55d126 100644 --- a/packages/core/core-flows/src/fulfillment/steps/validate-shipping-option-prices.ts +++ b/packages/core/core-flows/src/fulfillment/steps/validate-shipping-option-prices.ts @@ -1,26 +1,85 @@ import { FulfillmentWorkflow } from "@medusajs/framework/types" -import { MedusaError, Modules } from "@medusajs/framework/utils" +import { + MedusaError, + Modules, + ShippingOptionPriceType, +} from "@medusajs/framework/utils" import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +type OptionsInput = ( + | FulfillmentWorkflow.CreateShippingOptionsWorkflowInput + | FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput +)[] + export const validateShippingOptionPricesStepId = "validate-shipping-option-prices" /** - * Validate that regions exist for the shipping option prices. + * Validate that shipping options can be crated based on provided price configuration. + * + * For flat rate prices, it validates that regions exist for the shipping option prices. + * For calculated prices, it validates with the fulfillment provider if the price can be calculated. */ export const validateShippingOptionPricesStep = createStep( validateShippingOptionPricesStepId, - async ( - options: { - prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"] - }[], - { container } - ) => { - const allPrices = options.flatMap((option) => option.prices ?? []) + async (options: OptionsInput, { container }) => { + const fulfillmentModuleService = container.resolve(Modules.FULFILLMENT) + + const optionIds = options.map( + (option) => + (option as FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput).id + ) + + if (optionIds.length) { + /** + * This means we are validating an update of shipping options. + * We need to ensure that all shipping options have price_type set + * to correctly determine price updates. + * + * (On create, price_type must be defined already.) + */ + const shippingOptions = + await fulfillmentModuleService.listShippingOptions( + { + id: optionIds, + }, + { select: ["id", "price_type", "provider_id"] } + ) + + const optionsMap = new Map( + shippingOptions.map((option) => [option.id, option]) + ) + + ;( + options as FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput[] + ).forEach((option) => { + option.price_type = + option.price_type ?? optionsMap.get(option.id)?.price_type + option.provider_id = + option.provider_id ?? optionsMap.get(option.id)?.provider_id + }) + } + + const flatRatePrices: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[] = + [] + const calculatedOptions: OptionsInput = [] + + options.forEach((option) => { + if (option.price_type === ShippingOptionPriceType.FLAT) { + flatRatePrices.push(...(option.prices ?? [])) + } + if (option.price_type === ShippingOptionPriceType.CALCULATED) { + calculatedOptions.push(option) + } + }) + + await fulfillmentModuleService.validateShippingOptionsForPriceCalculation( + calculatedOptions as FulfillmentWorkflow.CreateShippingOptionsWorkflowInput[] + ) const regionIdSet = new Set() - allPrices.forEach((price) => { + flatRatePrices.forEach((price) => { if ("region_id" in price && price.region_id) { regionIdSet.add(price.region_id) } diff --git a/packages/core/core-flows/src/fulfillment/workflows/create-shipping-options.ts b/packages/core/core-flows/src/fulfillment/workflows/create-shipping-options.ts index dda9dbef0d213..e8a6de0caa38e 100644 --- a/packages/core/core-flows/src/fulfillment/workflows/create-shipping-options.ts +++ b/packages/core/core-flows/src/fulfillment/workflows/create-shipping-options.ts @@ -33,7 +33,13 @@ export const createShippingOptionsWorkflow = createWorkflow( const data = transform(input, (data) => { const shippingOptionsIndexToPrices = data.map((option, index) => { - const prices = option.prices + /** + * Flat rate ShippingOptions always needs to provide a price array. + * + * For calculated pricing we create an "empty" price set + * so we can have simpler update flow for both cases and allow updating price_type. + */ + const prices = (option as any).prices ?? [] return { shipping_option_index: index, prices, diff --git a/packages/core/core-flows/src/fulfillment/workflows/update-shipping-options.ts b/packages/core/core-flows/src/fulfillment/workflows/update-shipping-options.ts index 0c2a624b0645e..9f85d9eec93b9 100644 --- a/packages/core/core-flows/src/fulfillment/workflows/update-shipping-options.ts +++ b/packages/core/core-flows/src/fulfillment/workflows/update-shipping-options.ts @@ -12,6 +12,7 @@ import { } from "../steps" import { validateFulfillmentProvidersStep } from "../steps/validate-fulfillment-providers" import { validateShippingOptionPricesStep } from "../steps/validate-shipping-option-prices" +import { ShippingOptionPriceType } from "@medusajs/framework/utils" export const updateShippingOptionsWorkflowId = "update-shipping-options-workflow" @@ -32,11 +33,22 @@ export const updateShippingOptionsWorkflow = createWorkflow( const data = transform(input, (data) => { const shippingOptionsIndexToPrices = data.map((option, index) => { - const prices = option.prices - delete option.prices + const prices = ( + option as FulfillmentWorkflow.UpdateFlatRateShippingOptionInput + ).prices + + delete (option as FulfillmentWorkflow.UpdateFlatRateShippingOptionInput) + .prices + + /** + * When we are updating an option to be calculated, remove the prices. + */ + const isCalculatedOption = + option.price_type === ShippingOptionPriceType.CALCULATED + return { shipping_option_index: index, - prices, + prices: isCalculatedOption ? [] : prices, } }) @@ -58,8 +70,10 @@ export const updateShippingOptionsWorkflow = createWorkflow( (data) => { const shippingOptionsPrices = data.shippingOptionsIndexToPrices.map( ({ shipping_option_index, prices }) => { + const option = data.shippingOptions[shipping_option_index] + return { - id: data.shippingOptions[shipping_option_index].id, + id: option.id, prices, } } diff --git a/packages/core/types/src/fulfillment/provider.ts b/packages/core/types/src/fulfillment/provider.ts index 613a496c9f256..6729bc490aece 100644 --- a/packages/core/types/src/fulfillment/provider.ts +++ b/packages/core/types/src/fulfillment/provider.ts @@ -12,6 +12,11 @@ export type FulfillmentOption = { [k: string]: unknown } +export type CalculatedShippingOptionPrice = { + calculated_amount: number + is_calculated_price_tax_inclusive: boolean +} + export interface IFulfillmentProvider { /** * @@ -41,7 +46,7 @@ export interface IFulfillmentProvider { * * Check if the provider can calculate the fulfillment price. */ - canCalculate(data: Record): Promise + canCalculate(data: Record): Promise /** * * Calculate the price for the given fulfillment option. @@ -50,7 +55,7 @@ export interface IFulfillmentProvider { optionData: Record, data: Record, context: Record - ): Promise + ): Promise /** * * Create a fulfillment for the given data. diff --git a/packages/core/types/src/fulfillment/service.ts b/packages/core/types/src/fulfillment/service.ts index a0642822ebdab..46017e7b34a85 100644 --- a/packages/core/types/src/fulfillment/service.ts +++ b/packages/core/types/src/fulfillment/service.ts @@ -2625,6 +2625,28 @@ export interface IFulfillmentModuleService extends IModuleService { context: Record ): Promise + /** + * This method checks whether a shipping option can have calculated price. + * + * @param {FulfillmentTypes.CreateShippingOptionDTO[]} shippingOptionsData - The shipping options data to check. + * @returns {Promise} Whether the shipping options can have calculated price. + * + * @example + * const isValid = + * await fulfillmentModuleService.validateShippingOptionsForPriceCalculation( + * [ + * { + * provider_id: "webshipper", + * price_type: "calculated", + * }, + * ] + * ) + */ + validateShippingOptionsForPriceCalculation( + shippingOptionsData: CreateShippingOptionDTO[], + sharedContext?: Context + ): Promise + /** * This method retrieves a paginated list of fulfillment providers based on optional filters and configuration. * diff --git a/packages/core/types/src/workflow/fulfillment/create-shipping-options.ts b/packages/core/types/src/workflow/fulfillment/create-shipping-options.ts index bb207b8649afd..15a7563f2854c 100644 --- a/packages/core/types/src/workflow/fulfillment/create-shipping-options.ts +++ b/packages/core/types/src/workflow/fulfillment/create-shipping-options.ts @@ -1,28 +1,27 @@ -import { ShippingOptionDTO, ShippingOptionPriceType } from "../../fulfillment" +import { ShippingOptionDTO } from "../../fulfillment" import { RuleOperatorType } from "../../common" -export interface CreateShippingOptionsWorkflowInput { +type CreateFlatRateShippingOptionPriceRecord = + | { + currency_code: string + amount: number + } + | { + region_id: string + amount: number + } + +type CreateFlatShippingOptionInputBase = { name: string service_zone_id: string shipping_profile_id: string data?: Record - price_type: ShippingOptionPriceType provider_id: string type: { label: string description: string code: string } - prices: ( - | { - currency_code: string - amount: number - } - | { - region_id: string - amount: number - } - )[] rules?: { attribute: string operator: RuleOperatorType @@ -30,4 +29,17 @@ export interface CreateShippingOptionsWorkflowInput { }[] } +type CreateFlatRateShippingOptionInput = CreateFlatShippingOptionInputBase & { + price_type: "flat" + prices: CreateFlatRateShippingOptionPriceRecord[] +} + +type CreateCalculatedShippingOptionInput = CreateFlatShippingOptionInputBase & { + price_type: "calculated" +} + +export type CreateShippingOptionsWorkflowInput = + | CreateFlatRateShippingOptionInput + | CreateCalculatedShippingOptionInput + export type CreateShippingOptionsWorkflowOutput = ShippingOptionDTO[] diff --git a/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts b/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts index 4e1ad086a7e99..573be19684ba0 100644 --- a/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts +++ b/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts @@ -1,34 +1,18 @@ import { RuleOperatorType } from "../../common" -import { ShippingOptionPriceType } from "../../fulfillment" import { PriceRule } from "../../pricing" -export interface UpdateShippingOptionsWorkflowInput { +type UpdateFlatShippingOptionInputBase = { id: string name?: string service_zone_id?: string shipping_profile_id?: string data?: Record - price_type?: ShippingOptionPriceType provider_id?: string type?: { label: string description: string code: string } - prices?: ( - | { - id?: string - currency_code?: string - amount?: number - rules?: PriceRule[] - } - | { - id?: string - region_id?: string - amount?: number - rules?: PriceRule[] - } - )[] rules?: { attribute: string operator: RuleOperatorType @@ -36,6 +20,35 @@ export interface UpdateShippingOptionsWorkflowInput { }[] } +export type UpdateShippingOptionPriceRecord = + | { + id?: string + currency_code?: string + amount?: number + rules?: PriceRule[] + } + | { + id?: string + region_id?: string + amount?: number + rules?: PriceRule[] + } + +export type UpdateCalculatedShippingOptionInput = + UpdateFlatShippingOptionInputBase & { + price_type?: "calculated" + } + +export type UpdateFlatRateShippingOptionInput = + UpdateFlatShippingOptionInputBase & { + price_type?: "flat" + prices?: UpdateShippingOptionPriceRecord[] + } + +export type UpdateShippingOptionsWorkflowInput = + | UpdateFlatRateShippingOptionInput + | UpdateCalculatedShippingOptionInput + export type UpdateShippingOptionsWorkflowOutput = { id: string }[] diff --git a/packages/core/utils/src/fulfillment/provider.ts b/packages/core/utils/src/fulfillment/provider.ts index 53715f6a05043..781c2cf58dff8 100644 --- a/packages/core/utils/src/fulfillment/provider.ts +++ b/packages/core/utils/src/fulfillment/provider.ts @@ -1,4 +1,8 @@ -import { FulfillmentOption, IFulfillmentProvider } from "@medusajs/types" +import { + CalculatedShippingOptionPrice, + FulfillmentOption, + IFulfillmentProvider, +} from "@medusajs/types" /** * ### constructor @@ -221,7 +225,7 @@ export class AbstractFulfillmentProviderService optionData: Record, data: Record, context: Record - ): Promise { + ): Promise { throw Error("calculatePrice must be overridden by the child class") } @@ -345,7 +349,9 @@ export class AbstractFulfillmentProviderService * } * } */ - async createReturnFulfillment(fulfillment: Record): Promise { + async createReturnFulfillment( + fulfillment: Record + ): Promise { throw Error("createReturn must be overridden by the child class") } diff --git a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts index 486ecf19e663b..dd43d15ac299b 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts @@ -1970,6 +1970,34 @@ export default class FulfillmentModuleService return !!shippingOptions.length } + @InjectManager() + async validateShippingOptionsForPriceCalculation( + shippingOptionsData: FulfillmentTypes.CreateShippingOptionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const nonCalculatedOptions = shippingOptionsData.filter( + (option) => option.price_type !== "calculated" + ) + + if (nonCalculatedOptions.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot calculate price for non-calculated shipping options: ${nonCalculatedOptions + .map((o) => o.name) + .join(", ")}` + ) + } + + const promises = shippingOptionsData.map((option) => + this.fulfillmentProviderService_.canCalculate( + option.provider_id, + option as unknown as Record + ) + ) + + return await promiseAll(promises) + } + @InjectTransactionManager() // @ts-expect-error async deleteShippingProfiles( diff --git a/packages/modules/fulfillment/src/services/fulfillment-provider.ts b/packages/modules/fulfillment/src/services/fulfillment-provider.ts index ca926f66d58dd..16876730095d8 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-provider.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-provider.ts @@ -100,6 +100,21 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.MedusaIn return await provider.validateOption(data) } + async canCalculate(providerId: string, data: Record) { + const provider = this.retrieveProviderRegistration(providerId) + return await provider.canCalculate(data) + } + + async calculatePrice( + providerId: string, + optionData: Record, + data: Record, + context: Record + ) { + const provider = this.retrieveProviderRegistration(providerId) + return await provider.calculatePrice(optionData, data, context) + } + async createFulfillment( providerId: string, data: object, 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 5734782c92169..d560a4e051d80 100644 --- a/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts +++ b/packages/modules/providers/fulfillment-manual/src/services/manual-fulfillment.ts @@ -1,5 +1,8 @@ import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils" -import { FulfillmentOption } from "@medusajs/types" +import { + CalculatedShippingOptionPrice, + FulfillmentOption, +} from "@medusajs/types" // TODO rework type and DTO's @@ -30,6 +33,18 @@ export class ManualFulfillmentService extends AbstractFulfillmentProviderService return data } + async calculatePrice( + optionData: Record, + data: Record, + context: Record + ): Promise { + throw new Error("Manual fulfillment does not support price calculation") + } + + async canCalculate(): Promise { + return false + } + async validateOption(data: Record): Promise { return true }