feat(medusa, core-flows, fulfillment): calculate SO price endpoint (#10532)

**What**
- endpoint + flow for fetching calculated price for a shipping option

---

CLOSES CMRC-777
This commit is contained in:
Frane Polić
2024-12-12 09:03:56 +01:00
committed by GitHub
parent 52b494b62d
commit 472e92e400
18 changed files with 263 additions and 9 deletions

View File

@@ -0,0 +1,24 @@
import {
CalculateShippingOptionPriceDTO,
IFulfillmentModuleService,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const calculateShippingOptionsPricesStepId =
"calculate-shipping-options-prices"
/**
* This step calculates the prices for one or more shipping options.
*/
export const calculateShippingOptionsPricesStep = createStep(
calculateShippingOptionsPricesStepId,
async (input: CalculateShippingOptionPriceDTO[], { container }) => {
const service = container.resolve<IFulfillmentModuleService>(
Modules.FULFILLMENT
)
const prices = await service.calculateShippingOptionsPrices(input)
return new StepResponse(prices)
}
)

View File

@@ -15,3 +15,4 @@ export * from "./update-fulfillment"
export * from "./update-shipping-profiles"
export * from "./upsert-shipping-options"
export * from "./validate-shipment"
export * from "./calculate-shipping-options-prices"

View File

@@ -0,0 +1,63 @@
import { FulfillmentWorkflow } from "@medusajs/framework/types"
import {
createWorkflow,
transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { calculateShippingOptionsPricesStep } from "../steps"
import { useQueryGraphStep } from "../../common"
export const calculateShippingOptionsPricesWorkflowId =
"calculate-shipping-options-prices-workflow"
/**
* This workflow calculates the prices for one or more shipping options.
*/
export const calculateShippingOptionsPricesWorkflow = createWorkflow(
calculateShippingOptionsPricesWorkflowId,
(
input: WorkflowData<FulfillmentWorkflow.CalculateShippingOptionsPricesWorkflowInput>
): WorkflowResponse<FulfillmentWorkflow.CalculateShippingOptionsPricesWorkflowOutput> => {
const ids = transform({ input }, ({ input }) =>
input.shipping_options.map((so) => so.id)
)
const shippingOptionsQuery = useQueryGraphStep({
entity: "shipping_option",
filters: { id: ids },
fields: ["id", "provider_id", "data"],
}).config({ name: "shipping-options-query" })
const cartQuery = useQueryGraphStep({
entity: "cart",
filters: { id: input.cart_id },
fields: ["id", "items.*", "shipping_address.*"],
}).config({ name: "cart-query" })
const data = transform(
{ shippingOptionsQuery, cartQuery, input },
({ shippingOptionsQuery, cartQuery, input }) => {
const shippingOptions = shippingOptionsQuery.data
const cart = cartQuery.data[0]
const shippingOptionDataMap = new Map(
input.shipping_options.map((so) => [so.id, so.data])
)
return shippingOptions.map((shippingOption) => ({
id: shippingOption.id,
provider_id: shippingOption.provider_id,
optionData: shippingOption.data,
data: shippingOptionDataMap.get(shippingOption.id) ?? {},
context: {
cart,
},
}))
}
)
const prices = calculateShippingOptionsPricesStep(data)
return new WorkflowResponse(prices)
}
)

View File

@@ -14,3 +14,4 @@ export * from "./update-fulfillment"
export * from "./update-service-zones"
export * from "./update-shipping-options"
export * from "./update-shipping-profiles"
export * from "./calculate-shipping-options-prices"

View File

@@ -1,6 +1,7 @@
import { CreateShippingOptionTypeDTO } from "./shipping-option-type"
import { ShippingOptionPriceType } from "../common"
import { CreateShippingOptionRuleDTO } from "./shipping-option-rule"
import { CartDTO } from "../../cart"
/**
* The shipping option to be created.
@@ -118,3 +119,39 @@ export interface UpdateShippingOptionDTO {
* A shipping option to be created or updated.
*/
export interface UpsertShippingOptionDTO extends UpdateShippingOptionDTO {}
/**
* The data needed for the associated fulfillment provider to calculate the price of a shipping option.
*/
export interface CalculateShippingOptionPriceDTO {
/**
* The ID of the shipping option.
*/
id: string
/**
* The ID of the fulfillment provider.
*/
provider_id: string
/**
* The option data from the provider.
*/
optionData: Record<string, unknown>
/**
* Additional data passed when the price is calculated.
*
* @example
* When calculating the price for a shipping option upon creation of a shipping method additional data can be passed
* to the provider.
*/
data: Record<string, unknown>
/**
* The calculation context needed for the associated fulfillment provider to calculate the price of a shipping option.
*/
context: {
cart: Pick<CartDTO, "id" | "items" | "shipping_address" | "email">
} & Record<string, unknown>
}

View File

@@ -13,7 +13,7 @@ export type FulfillmentOption = {
}
export type CalculatedShippingOptionPrice = {
calculated_amount: number
calculated_price: number
is_calculated_price_tax_inclusive: boolean
}

View File

@@ -24,6 +24,7 @@ import {
ShippingProfileDTO,
} from "./common"
import {
CalculateShippingOptionPriceDTO,
CreateFulfillmentSetDTO,
CreateGeoZoneDTO,
CreateServiceZoneDTO,
@@ -44,6 +45,7 @@ import {
CreateShippingProfileDTO,
UpsertShippingProfileDTO,
} from "./mutations/shipping-profile"
import { CalculatedShippingOptionPrice } from "./provider"
/**
* The main service interface for the Fulfillment Module.
@@ -2647,6 +2649,33 @@ export interface IFulfillmentModuleService extends IModuleService {
sharedContext?: Context
): Promise<boolean[]>
/**
* This method calculates the prices for one or more shipping options.
*
* @param {CalculateShippingOptionPriceDTO[]} shippingOptionsData - The shipping options data to calculate the prices for.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<CalculatedShippingOptionPrice[]>} The calculated shipping option prices.
*
* @example
* const prices =
* await fulfillmentModuleService.calculateShippingOptionsPrices(
* [
* {
* provider_id: "webshipper",
* data: {
* cart: {
* id: "cart_123",
* },
* },
* },
* ]
* )
*/
calculateShippingOptionsPrices(
shippingOptionsData: CalculateShippingOptionPriceDTO[],
sharedContext?: Context
): Promise<CalculatedShippingOptionPrice[]>
/**
* This method retrieves a paginated list of fulfillment providers based on optional filters and configuration.
*

View File

@@ -1,3 +1,4 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"
export * from "./payloads"

View File

@@ -0,0 +1,4 @@
export type StoreCalculateShippingOptionPrice = {
cart_id: string
data: Record<string, unknown>
}

View File

@@ -3,3 +3,7 @@ import { StoreCartShippingOption } from "../../fulfillment"
export interface StoreShippingOptionListResponse {
shipping_options: StoreCartShippingOption[]
}
export interface StoreShippingOptionResponse {
shipping_option: StoreCartShippingOption
}

View File

@@ -0,0 +1,9 @@
import { CalculatedShippingOptionPrice } from "../../fulfillment"
export type CalculateShippingOptionsPricesWorkflowInput = {
cart_id: string
shipping_options: { id: string; data: Record<string, unknown> }[]
}
export type CalculateShippingOptionsPricesWorkflowOutput =
CalculatedShippingOptionPrice[]

View File

@@ -6,3 +6,4 @@ export * from "./service-zones"
export * from "./shipping-profiles"
export * from "./update-fulfillment"
export * from "./update-shipping-options"
export * from "./calculate-shipping-options-prices"

View File

@@ -202,19 +202,18 @@ export class AbstractFulfillmentProviderService
* The Medusa application uses the {@link canCalculate} method first to check whether the shipping option's price is calculated.
* If it returns `true`, Medusa uses this method to retrieve the calculated price.
*
* @param optionData - The `data` property of a shipping option.
* @param data - If the price is calculated for a shipping option, it's the `data` of the shipping option. Otherwise, it's the `data of the shipping method.
* @param cart - The cart details.
* @param optionData - Shipping option data from the provider, the `data` property of a shipping option.
* @param data - Additional data passed when the price is calculated.
* @param context - The context details, such as the cart or customer.
* @returns The calculated price
*
* @example
* class MyFulfillmentProviderService extends AbstractFulfillmentProviderService {
* // ...
* async calculatePrice(optionData: any, data: any, cart: any): Promise<number> {
* async calculatePrice(optionData: any, data: any, context: any): Promise<number> {
* // assuming the client can calculate the price using
* // the third-party service
* const price = await this.client.calculate(data)
*
* return price
* }
* }

View File

@@ -0,0 +1,31 @@
import { HttpTypes } from "@medusajs/framework/types"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { calculateShippingOptionsPricesWorkflow } from "@medusajs/core-flows"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export const POST = async (
req: MedusaRequest<HttpTypes.StoreCalculateShippingOptionPrice>,
res: MedusaResponse<HttpTypes.StoreShippingOptionResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { result } = await calculateShippingOptionsPricesWorkflow(
req.scope
).run({
input: {
shipping_options: [{ id: req.params.id, data: req.validatedBody.data }],
cart_id: req.validatedBody.cart_id,
},
})
const { data } = await query.graph({
entity: "shipping_option",
fields: req.remoteQueryConfig.fields,
filters: { id: req.params.id },
})
const shippingOption = data[0]
const priceData = result[0]
res.status(200).json({ shipping_option: { ...shippingOption, ...priceData } })
}

View File

@@ -1,7 +1,15 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
MiddlewareRoute,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { validateAndTransformQuery } from "@medusajs/framework"
import { listTransformQueryConfig } from "./query-config"
import { StoreGetShippingOptions } from "./validators"
import {
StoreCalculateShippingOptionPrice,
StoreGetShippingOptions,
StoreGetShippingOptionsParams,
} from "./validators"
import * as QueryConfig from "./query-config"
export const storeShippingOptionRoutesMiddlewares: MiddlewareRoute[] = [
{
@@ -14,4 +22,15 @@ export const storeShippingOptionRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/store/shipping-options/:id/calculate",
middlewares: [
validateAndTransformQuery(
StoreGetShippingOptionsParams,
QueryConfig.retrieveTransformQueryConfig
),
validateAndTransformBody(StoreCalculateShippingOptionPrice),
],
},
]

View File

@@ -12,3 +12,8 @@ export const listTransformQueryConfig = {
defaultLimit: 20,
isList: true,
}
export const retrieveTransformQueryConfig = {
defaults: defaultStoreShippingOptionsFields,
isList: false,
}

View File

@@ -1,6 +1,8 @@
import { z } from "zod"
import { applyAndAndOrOperators } from "../../utils/common-validators"
import { createFindParams } from "../../utils/validators"
import { createFindParams, createSelectParams } from "../../utils/validators"
export const StoreGetShippingOptionsParams = createSelectParams()
export const StoreGetShippingOptionsFields = z
.object({
@@ -18,3 +20,11 @@ export const StoreGetShippingOptions = createFindParams({
})
.merge(StoreGetShippingOptionsFields)
.merge(applyAndAndOrOperators(StoreGetShippingOptionsFields))
export type StoreCalculateShippingOptionPriceType = z.infer<
typeof StoreCalculateShippingOptionPrice
>
export const StoreCalculateShippingOptionPrice = z.object({
cart_id: z.string(),
data: z.record(z.string(), z.unknown()),
})

View File

@@ -1,4 +1,5 @@
import {
CalculatedShippingOptionPrice,
Context,
DAL,
FilterableFulfillmentSetProps,
@@ -1998,6 +1999,21 @@ export default class FulfillmentModuleService
return await promiseAll(promises)
}
async calculateShippingOptionsPrices(
shippingOptionsData: FulfillmentTypes.CalculateShippingOptionPriceDTO[]
): Promise<CalculatedShippingOptionPrice[]> {
const promises = shippingOptionsData.map((data) =>
this.fulfillmentProviderService_.calculatePrice(
data.provider_id,
data.optionData,
data.data,
data.context
)
)
return await promiseAll(promises)
}
@InjectTransactionManager()
// @ts-expect-error
async deleteShippingProfiles(