Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core-flows, dashboard, fulfillment, fulfillment-manual, utils, types): create shipping options with calculated prices #10495

Merged
merged 17 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"

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"]
}[],
options: (
| FulfillmentWorkflow.CreateShippingOptionsWorkflowInput
| FulfillmentWorkflow.UpdateShippingOptionsWorkflowInput
)[],
{ container }
) => {
const allPrices = options.flatMap((option) => option.prices ?? [])
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 = options.flatMap((option) =>
option.price_type === ShippingOptionPriceType.FLAT
? ((option.prices ?? []) as { region_id: string; amount: number }[])
: []
)

const calculatedOptions = options
.filter(
(option) => option.price_type === ShippingOptionPriceType.CALCULATED
)
.flatMap((option) => option ?? [])
fPolic marked this conversation as resolved.
Show resolved Hide resolved

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 ?? []
Copy link
Contributor Author

@fPolic fPolic Dec 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment: we always create associated price set for a shipping option, both calculated and flat rate, to make the implementation of create and update flows simpler and more unified (e.g. we don't have to duplicate the logic of price set creation in the update flow if the option price type changes to "flat rate")

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,18 @@ 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 any).prices
delete (option as any).prices
fPolic marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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 +66,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,37 @@
import { ShippingOptionDTO, ShippingOptionPriceType } from "../../fulfillment"
import { ShippingOptionDTO } from "../../fulfillment"
import { RuleOperatorType } from "../../common"

export interface CreateShippingOptionsWorkflowInput {
export type CreateShippingOptionsWorkflowInput = {
name: string
fPolic marked this conversation as resolved.
Show resolved Hide resolved
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[]
}[]
}
} & (
| { price_type: "calculated" }
| {
price_type: "flat"
prices: (
| {
currency_code: string
amount: number
}
| {
region_id: string
amount: number
}
)[]
}
)

export type CreateShippingOptionsWorkflowOutput = ShippingOptionDTO[]
Original file line number Diff line number Diff line change
@@ -1,40 +1,44 @@
import { RuleOperatorType } from "../../common"
import { ShippingOptionPriceType } from "../../fulfillment"
import { PriceRule } from "../../pricing"

export interface UpdateShippingOptionsWorkflowInput {
export type UpdateShippingOptionsWorkflowInput = {
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[]
}[]
}
} & (
| { price_type?: "calculated" }
| {
price_type?: "flat"
prices?: UpdateShippingOptionPriceRecord[]
}
)

export type UpdateShippingOptionPriceRecord =
| {
id?: string
currency_code?: string
amount?: number
rules?: PriceRule[]
}
| {
id?: string
region_id?: string
amount?: number
rules?: PriceRule[]
}

export type UpdateShippingOptionsWorkflowOutput = {
id: string
Expand Down
12 changes: 9 additions & 3 deletions packages/core/utils/src/fulfillment/provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { FulfillmentOption, IFulfillmentProvider } from "@medusajs/types"
import {
CalculatedShippingOptionPrice,
FulfillmentOption,
IFulfillmentProvider,
} from "@medusajs/types"

/**
* ### constructor
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")
}

Expand Down
Loading
Loading