feat(core-flows, dashboard, fulfillment, fulfillment-manual, utils, types): create shipping options with calculated prices (#10495)
**What** - support creating SO with calculated price - support updating SO for both types of pricing - update `validateShippingOptionPricesStep` to handle both SO price_types - add the `validateShippingOptionsForPriceCalculation` method to `FulfillementModule` - add `canCalculate` and `calculatePrice` to fulfillment provider service service / interface / manual provider - disable SO pricing edit on Admin if SO price type is calculated --- CLOSES CMRC-776
This commit is contained in:
@@ -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: <CurrencyDollar />,
|
||||
disabled:
|
||||
option.price_type === ShippingOptionPriceType.Calculated,
|
||||
to: `/settings/locations/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/pricing`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -25,7 +25,7 @@ interface PriceRegionId {
|
||||
|
||||
export type SetShippingOptionsPricesStepInput = {
|
||||
id: string
|
||||
prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"]
|
||||
prices?: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[]
|
||||
}[]
|
||||
|
||||
async function getCurrentShippingOptionPrices(
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
allPrices.forEach((price) => {
|
||||
flatRatePrices.forEach((price) => {
|
||||
if ("region_id" in price && price.region_id) {
|
||||
regionIdSet.add(price.region_id)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<any>
|
||||
canCalculate(data: Record<string, unknown>): Promise<boolean>
|
||||
/**
|
||||
*
|
||||
* Calculate the price for the given fulfillment option.
|
||||
@@ -50,7 +55,7 @@ export interface IFulfillmentProvider {
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<any>
|
||||
): Promise<CalculatedShippingOptionPrice>
|
||||
/**
|
||||
*
|
||||
* Create a fulfillment for the given data.
|
||||
|
||||
@@ -2625,6 +2625,28 @@ export interface IFulfillmentModuleService extends IModuleService {
|
||||
context: Record<string, unknown>
|
||||
): Promise<boolean>
|
||||
|
||||
/**
|
||||
* This method checks whether a shipping option can have calculated price.
|
||||
*
|
||||
* @param {FulfillmentTypes.CreateShippingOptionDTO[]} shippingOptionsData - The shipping options data to check.
|
||||
* @returns {Promise<boolean[]>} 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<boolean[]>
|
||||
|
||||
/**
|
||||
* This method retrieves a paginated list of fulfillment providers based on optional filters and configuration.
|
||||
*
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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[]
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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
|
||||
}[]
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
): Promise<CalculatedShippingOptionPrice> {
|
||||
throw Error("calculatePrice must be overridden by the child class")
|
||||
}
|
||||
|
||||
@@ -345,7 +349,9 @@ export class AbstractFulfillmentProviderService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
async createReturnFulfillment(fulfillment: Record<string, unknown>): Promise<any> {
|
||||
async createReturnFulfillment(
|
||||
fulfillment: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
throw Error("createReturn must be overridden by the child class")
|
||||
}
|
||||
|
||||
|
||||
@@ -1970,6 +1970,34 @@ export default class FulfillmentModuleService
|
||||
return !!shippingOptions.length
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
async validateShippingOptionsForPriceCalculation(
|
||||
shippingOptionsData: FulfillmentTypes.CreateShippingOptionDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<boolean[]> {
|
||||
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<string, unknown>
|
||||
)
|
||||
)
|
||||
|
||||
return await promiseAll(promises)
|
||||
}
|
||||
|
||||
@InjectTransactionManager()
|
||||
// @ts-expect-error
|
||||
async deleteShippingProfiles(
|
||||
|
||||
@@ -100,6 +100,21 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.MedusaIn
|
||||
return await provider.validateOption(data)
|
||||
}
|
||||
|
||||
async canCalculate(providerId: string, data: Record<string, unknown>) {
|
||||
const provider = this.retrieveProviderRegistration(providerId)
|
||||
return await provider.canCalculate(data)
|
||||
}
|
||||
|
||||
async calculatePrice(
|
||||
providerId: string,
|
||||
optionData: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
) {
|
||||
const provider = this.retrieveProviderRegistration(providerId)
|
||||
return await provider.calculatePrice(optionData, data, context)
|
||||
}
|
||||
|
||||
async createFulfillment(
|
||||
providerId: string,
|
||||
data: object,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
): Promise<CalculatedShippingOptionPrice> {
|
||||
throw new Error("Manual fulfillment does not support price calculation")
|
||||
}
|
||||
|
||||
async canCalculate(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async validateOption(data: Record<string, any>): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user