Skip to content

Commit

Permalink
feat(core-flows, dashboard, fulfillment, fulfillment-manual, utils, t…
Browse files Browse the repository at this point in the history
…ypes): 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
  • Loading branch information
fPolic authored Dec 11, 2024
1 parent fad85a9 commit d8a92db
Show file tree
Hide file tree
Showing 13 changed files with 253 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ interface PriceRegionId {

export type SetShippingOptionsPricesStepInput = {
id: string
prices?: FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput["prices"]
prices?: FulfillmentWorkflow.UpdateShippingOptionPriceRecord[]
}[]

async function getCurrentShippingOptionPrices(
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
}
})

Expand All @@ -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,
}
}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/types/src/fulfillment/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
*
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions packages/core/types/src/fulfillment/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,45 @@
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
value: string | string[]
}[]
}

type CreateFlatRateShippingOptionInput = CreateFlatShippingOptionInputBase & {
price_type: "flat"
prices: CreateFlatRateShippingOptionPriceRecord[]
}

type CreateCalculatedShippingOptionInput = CreateFlatShippingOptionInputBase & {
price_type: "calculated"
}

export type CreateShippingOptionsWorkflowInput =
| CreateFlatRateShippingOptionInput
| CreateCalculatedShippingOptionInput

export type CreateShippingOptionsWorkflowOutput = ShippingOptionDTO[]
Original file line number Diff line number Diff line change
@@ -1,41 +1,54 @@
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
value: string | string[]
}[]
}

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
}[]
Loading

0 comments on commit d8a92db

Please sign in to comment.