From 0c49470066bb5424070836c216fca34056ce5f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Mon, 16 Dec 2024 23:28:30 +0100 Subject: [PATCH] feat(core-flows): calculate SO price on cart ops (#10563) **What** - calculate the shipping option price when creating a shipping method - calculate the shipping option price when refreshing cart - add testing for calculated SO flow - fix validation on calculated SO creation - add manual fulfillment provider for testing - add `from_location` to calculation context --- RESOLVES CMRC-778 RESOLVES CMRC-602 RESOLVES SUP-136 --- .../admin/fulfillment-provider.spec.ts | 18 +- .../store/shipping-option-calculated.spec.ts | 404 ++++++++++++++++++ integration-tests/http/medusa-config.js | 11 +- integration-tests/http/package.json | 2 +- .../fulfillment-manual-calculated/index.ts | 8 + .../services/manual-fulfillment.ts | 54 +++ integration-tests/http/tsconfig.json | 12 + .../workflows/add-shipping-method-to-cart.ts | 17 +- .../core-flows/src/cart/workflows/index.ts | 1 + ...-shipping-options-for-cart-with-pricing.ts | 275 ++++++++++++ .../refresh-cart-shipping-methods.ts | 29 +- .../steps/validate-shipping-option-prices.ts | 17 +- .../calculate-shipping-options-prices.ts | 61 ++- .../fulfillment/mutations/shipping-option.ts | 8 +- .../core/types/src/fulfillment/provider.ts | 10 +- .../http/shipping-option/store/payloads.ts | 2 +- .../calculate-shipping-options-prices.ts | 2 +- .../core/utils/src/fulfillment/provider.ts | 7 +- .../shipping-options/[id]/calculate/route.ts | 8 +- .../api/store/shipping-options/validators.ts | 2 +- .../src/services/fulfillment-provider.ts | 7 +- 21 files changed, 903 insertions(+), 52 deletions(-) create mode 100644 integration-tests/http/__tests__/shipping-option/store/shipping-option-calculated.spec.ts create mode 100644 integration-tests/http/src/utils/providers/fulfillment-manual-calculated/index.ts create mode 100644 integration-tests/http/src/utils/providers/fulfillment-manual-calculated/services/manual-fulfillment.ts create mode 100644 integration-tests/http/tsconfig.json create mode 100644 packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart-with-pricing.ts diff --git a/integration-tests/http/__tests__/fulfillment/admin/fulfillment-provider.spec.ts b/integration-tests/http/__tests__/fulfillment/admin/fulfillment-provider.spec.ts index 58ae5a1f9e736..da2091356ebf6 100644 --- a/integration-tests/http/__tests__/fulfillment/admin/fulfillment-provider.spec.ts +++ b/integration-tests/http/__tests__/fulfillment/admin/fulfillment-provider.spec.ts @@ -31,12 +31,18 @@ medusaIntegrationTestRunner({ ) expect(response.status).toEqual(200) - expect(response.data.fulfillment_providers).toEqual([ - expect.objectContaining({ - id: "manual_test-provider", - is_enabled: true, - }), - ]) + expect(response.data.fulfillment_providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "manual_test-provider", + is_enabled: true, + }), + expect.objectContaining({ + id: "manual-calculated_test-provider-calculated", + is_enabled: true, + }), + ]) + ) }) it("should list all fulfillment providers scoped by stock location", async () => { diff --git a/integration-tests/http/__tests__/shipping-option/store/shipping-option-calculated.spec.ts b/integration-tests/http/__tests__/shipping-option/store/shipping-option-calculated.spec.ts new file mode 100644 index 0000000000000..0294dfaf66d7f --- /dev/null +++ b/integration-tests/http/__tests__/shipping-option/store/shipping-option-calculated.spec.ts @@ -0,0 +1,404 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + createAdminUser, + generatePublishableKey, + generateStoreHeaders, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Store: Shipping Option API", () => { + let appContainer + let salesChannel + let region + let regionTwo + let product + let stockLocation + let shippingProfile + let fulfillmentSet + let cart + let shippingOptionCalculated + let shippingOptionFlat + let storeHeaders + + beforeAll(async () => { + appContainer = getContainer() + }) + + beforeEach(async () => { + const publishableKey = await generatePublishableKey(appContainer) + storeHeaders = generateStoreHeaders({ publishableKey }) + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + region = ( + await api.post( + "/admin/regions", + { name: "US", currency_code: "usd", countries: ["US"] }, + adminHeaders + ) + ).data.region + + regionTwo = ( + await api.post( + "/admin/regions", + { + name: "Test region two", + currency_code: "dkk", + countries: ["DK"], + is_tax_inclusive: true, + }, + adminHeaders + ) + ).data.region + + salesChannel = ( + await api.post( + "/admin/sales-channels", + { name: "first channel", description: "channel" }, + adminHeaders + ) + ).data.sales_channel + + product = ( + await api.post( + "/admin/products", + { + title: "Test fixture", + options: [ + { title: "size", values: ["large", "small"] }, + { title: "color", values: ["green"] }, + ], + variants: [ + { + title: "Test variant", + manage_inventory: false, + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "dkk", + amount: 100, + }, + ], + options: { + size: "large", + color: "green", + }, + }, + ], + }, + adminHeaders + ) + ).data.product + + stockLocation = ( + await api.post( + `/admin/stock-locations`, + { name: "test location" }, + adminHeaders + ) + ).data.stock_location + + await api.post( + `/admin/stock-locations/${stockLocation.id}/sales-channels`, + { add: [salesChannel.id] }, + adminHeaders + ) + + shippingProfile = ( + await api.post( + `/admin/shipping-profiles`, + { name: "Test", type: "default" }, + adminHeaders + ) + ).data.shipping_profile + + const fulfillmentSets = ( + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`, + { + name: "Test", + type: "test-type", + }, + adminHeaders + ) + ).data.stock_location.fulfillment_sets + + fulfillmentSet = ( + await api.post( + `/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`, + { + name: "Test", + geo_zones: [ + { type: "country", country_code: "us" }, + { type: "country", country_code: "dk" }, + ], + }, + adminHeaders + ) + ).data.fulfillment_set + + await api.post( + `/admin/stock-locations/${stockLocation.id}/fulfillment-providers`, + { + add: [ + "manual_test-provider", + "manual-calculated_test-provider-calculated", + ], + }, + adminHeaders + ) + + shippingOptionCalculated = ( + await api.post( + `/admin/shipping-options`, + { + name: "Calculated shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual-calculated_test-provider-calculated", + price_type: "calculated", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [], // TODO: Update endpoint validator to not require prices if type is calculated + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + + shippingOptionFlat = ( + await api.post( + `/admin/shipping-options`, + { + name: "Flat rate shipping option", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: "manual_test-provider", + price_type: "flat", + type: { + label: "Test type", + description: "Test description", + code: "test-code", + }, + prices: [ + { + currency_code: "usd", + amount: 1000, + }, + { + region_id: region.id, + amount: 1100, + }, + { + region_id: region.id, + amount: 0, + rules: [ + { + operator: "gt", + attribute: "item_total", + value: 2000, + }, + ], + }, + { + region_id: regionTwo.id, + amount: 500, + }, + ], + rules: [], + }, + adminHeaders + ) + ).data.shipping_option + }) + + describe("GET /store/shipping-options?cart_id=", () => { + it("should get calculated and flat rate shipping options for a cart successfully", async () => { + cart = ( + await api.post( + `/store/carts`, + { + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + email: "test@admin.com", + items: [ + { + variant_id: product.variants[0].id, + quantity: 2, + }, + ], + }, + storeHeaders + ) + ).data.cart + + const resp = await api.get( + `/store/shipping-options?cart_id=${cart.id}`, + storeHeaders + ) + + const shippingOptions = resp.data.shipping_options + + expect(shippingOptions).toHaveLength(2) + expect(shippingOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: shippingOptionFlat.id, + name: "Flat rate shipping option", + price_type: "flat", + amount: 1100, + is_tax_inclusive: false, + provider_id: "manual_test-provider", + calculated_price: expect.objectContaining({ + calculated_amount: 1100, + is_calculated_price_tax_inclusive: false, + }), + }), + expect.objectContaining({ + id: shippingOptionCalculated.id, + name: "Calculated shipping option", + price_type: "calculated", + provider_id: "manual-calculated_test-provider-calculated", + calculated_price: null, + prices: [], + // amount doesn't exist for calculated shipping options -> /calculate needs to be called + }), + ]) + ) + }) + + it("should get fetch pricing for calculated shipping options", async () => { + cart = ( + await api.post( + `/store/carts`, + { + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + email: "test@admin.com", + items: [ + { + variant_id: product.variants[0].id, + quantity: 2, + }, + ], + }, + storeHeaders + ) + ).data.cart + + const resp = await api.post( + `/store/shipping-options/${shippingOptionCalculated.id}/calculate?fields=+provider_id`, + { cart_id: cart.id, data: { pin_id: "test" } }, + storeHeaders + ) + + const shippingOption = resp.data.shipping_option + + expect(shippingOption).toEqual( + expect.objectContaining({ + id: shippingOptionCalculated.id, + name: "Calculated shipping option", + price_type: "calculated", + provider_id: "manual-calculated_test-provider-calculated", + calculated_price: expect.objectContaining({ + calculated_amount: 3, + is_calculated_price_tax_inclusive: false, + }), + amount: 3, + }) + ) + }) + + it("should add shipping method with calculated price to cart", async () => { + cart = ( + await api.post( + `/store/carts`, + { + region_id: region.id, + sales_channel_id: salesChannel.id, + currency_code: "usd", + email: "test@admin.com", + items: [ + { + variant_id: product.variants[0].id, + quantity: 2, + }, + ], + }, + storeHeaders + ) + ).data.cart + + // Select shipping option and create shipping method + + let response = await api.post( + `/store/carts/${cart.id}/shipping-methods?fields=*shipping_methods`, + { + option_id: shippingOptionCalculated.id, + data: { pin_id: "test" }, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOptionCalculated.id, + amount: 3, + is_tax_inclusive: false, + data: { pin_id: "test" }, + }), + ]), + shipping_total: 3, + }) + ) + + // Update cart and refresh shipping methods + + response = await api.post( + `/store/carts/${cart.id}/line-items?fields=*shipping_methods`, + { + variant_id: product.variants[0].id, + quantity: 1, + }, + storeHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + shipping_methods: expect.arrayContaining([ + expect.objectContaining({ + shipping_option_id: shippingOptionCalculated.id, + amount: 4.5, + is_tax_inclusive: false, + data: { pin_id: "test" }, + }), + ]), + shipping_subtotal: 4.5, + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/http/medusa-config.js b/integration-tests/http/medusa-config.js index 4789cc00504f0..27673687d5681 100644 --- a/integration-tests/http/medusa-config.js +++ b/integration-tests/http/medusa-config.js @@ -15,6 +15,12 @@ const customFulfillmentProvider = { id: "test-provider", } +const customFulfillmentProviderCalculated = { + resolve: require("./dist/utils/providers/fulfillment-manual-calculated") + .default, + id: "test-provider-calculated", +} + module.exports = defineConfig({ admin: { disable: true, @@ -28,7 +34,10 @@ module.exports = defineConfig({ [Modules.FULFILLMENT]: { /** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */ options: { - providers: [customFulfillmentProvider], + providers: [ + customFulfillmentProvider, + customFulfillmentProviderCalculated, + ], }, }, [Modules.NOTIFICATION]: { diff --git a/integration-tests/http/package.json b/integration-tests/http/package.json index 38557315b197e..d4c10b5bd2b9c 100644 --- a/integration-tests/http/package.json +++ b/integration-tests/http/package.json @@ -7,7 +7,7 @@ "scripts": { "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-cache --maxWorkers=50% --bail --detectOpenHandles --forceExit --logHeapUsage", "test:integration:chunk": "NODE_OPTIONS=--experimental-vm-modules jest --silent --no-cache --bail --maxWorkers=50% --forceExit --testPathPattern=$(echo $CHUNKS | jq -r \".[${CHUNK}] | .[]\")", - "build": "tsc ./src/* --allowJs --outDir ./dist" + "build": "tsc --allowJs --outDir ./dist" }, "dependencies": { "@medusajs/api-key": "workspace:^", diff --git a/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/index.ts b/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/index.ts new file mode 100644 index 0000000000000..93b20068a8207 --- /dev/null +++ b/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/index.ts @@ -0,0 +1,8 @@ +import { ModuleProvider, Modules } from "@medusajs/framework/utils" +import { ManualFulfillmentService } from "./services/manual-fulfillment" + +const services = [ManualFulfillmentService] + +export default ModuleProvider(Modules.FULFILLMENT, { + services, +}) diff --git a/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/services/manual-fulfillment.ts b/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/services/manual-fulfillment.ts new file mode 100644 index 0000000000000..dcba7c6cc9599 --- /dev/null +++ b/integration-tests/http/src/utils/providers/fulfillment-manual-calculated/services/manual-fulfillment.ts @@ -0,0 +1,54 @@ +import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils" + +export class ManualFulfillmentService extends AbstractFulfillmentProviderService { + static identifier = "manual-calculated" + + constructor() { + super() + } + + async getFulfillmentOptions() { + return [ + { + id: "manual-fulfillment-calculated", + }, + { + id: "manual-fulfillment-return-calculated", + is_return: true, + }, + ] + } + + async validateFulfillmentData(optionData, data, context) { + return data + } + + async calculatePrice(optionData, data, context) { + return { + calculated_amount: + context.items.reduce((acc, i) => acc + i.quantity, 0) * 1.5, // mock caluclation as 1.5 per item + is_calculated_price_tax_inclusive: false, + } + } + + async canCalculate() { + return true + } + + async validateOption(data) { + return true + } + + async createFulfillment() { + // No data is being sent anywhere + return {} + } + + async cancelFulfillment() { + return {} + } + + async createReturnFulfillment() { + return {} + } +} diff --git a/integration-tests/http/tsconfig.json b/integration-tests/http/tsconfig.json new file mode 100644 index 0000000000000..55e69b061484d --- /dev/null +++ b/integration-tests/http/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../_tsconfig.base.json", + "include": ["src", "./medusa/**/*"], + "exclude": [ + "dist", + "__tests__", + "helpers", + "./**/helpers", + "./**/__snapshots__", + "node_modules" + ] +} diff --git a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts index d097137586e85..f49da2c7fafc2 100644 --- a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts @@ -16,7 +16,7 @@ import { validateCartStep } from "../steps/validate-cart" import { validateAndReturnShippingMethodsDataStep } from "../steps/validate-shipping-methods-data" import { validateCartShippingOptionsPriceStep } from "../steps/validate-shipping-options-price" import { cartFieldsForRefreshSteps } from "../utils/fields" -import { listShippingOptionsForCartWorkflow } from "./list-shipping-options-for-cart" +import { listShippingOptionsForCartWithPricingWorkflow } from "./list-shipping-options-for-cart-with-pricing" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" @@ -56,13 +56,14 @@ export const addShippingMethodToCartWorkflow = createWorkflow( shippingOptionsContext: { is_return: "false", enabled_in_store: "true" }, }) - const shippingOptions = listShippingOptionsForCartWorkflow.runAsStep({ - input: { - option_ids: optionIds, - cart_id: cart.id, - is_return: false, - }, - }) + const shippingOptions = + listShippingOptionsForCartWithPricingWorkflow.runAsStep({ + input: { + options: input.options, + cart_id: cart.id, + is_return: false, + }, + }) validateCartShippingOptionsPriceStep({ shippingOptions }) diff --git a/packages/core/core-flows/src/cart/workflows/index.ts b/packages/core/core-flows/src/cart/workflows/index.ts index 50fd22ef29256..28d5729075c24 100644 --- a/packages/core/core-flows/src/cart/workflows/index.ts +++ b/packages/core/core-flows/src/cart/workflows/index.ts @@ -5,6 +5,7 @@ export * from "./confirm-variant-inventory" export * from "./create-carts" export * from "./create-payment-collection-for-cart" export * from "./list-shipping-options-for-cart" +export * from "./list-shipping-options-for-cart-with-pricing" export * from "./refresh-payment-collection" export * from "./transfer-cart-customer" export * from "./update-cart" diff --git a/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart-with-pricing.ts b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart-with-pricing.ts new file mode 100644 index 0000000000000..5c5597d08bdf5 --- /dev/null +++ b/packages/core/core-flows/src/cart/workflows/list-shipping-options-for-cart-with-pricing.ts @@ -0,0 +1,275 @@ +import { ShippingOptionPriceType } from "@medusajs/framework/utils" +import { + createWorkflow, + parallelize, + transform, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep, validatePresenceOfStep } from "../../common" +import { useRemoteQueryStep } from "../../common/steps/use-remote-query" +import { calculateShippingOptionsPricesStep } from "../../fulfillment" +import { CalculateShippingOptionPriceDTO } from "@medusajs/types" + +const COMMON_OPTIONS_FIELDS = [ + "id", + "name", + "price_type", + "service_zone_id", + "service_zone.fulfillment_set_id", + "shipping_profile_id", + "provider_id", + "data", + + "type.id", + "type.label", + "type.description", + "type.code", + + "provider.id", + "provider.is_enabled", + + "rules.attribute", + "rules.value", + "rules.operator", +] + +export const listShippingOptionsForCartWithPricingWorkflowId = + "list-shipping-options-for-cart-with-pricing" +/** + * This workflow lists the shipping options of a cart. + */ +export const listShippingOptionsForCartWithPricingWorkflow = createWorkflow( + listShippingOptionsForCartWithPricingWorkflowId, + ( + input: WorkflowData<{ + cart_id: string + options?: { id: string; data?: Record }[] + is_return?: boolean + enabled_in_store?: boolean + }> + ) => { + const optionIds = transform({ input }, ({ input }) => + (input.options ?? []).map(({ id }) => id) + ) + + const cartQuery = useQueryGraphStep({ + entity: "cart", + filters: { id: input.cart_id }, + fields: [ + "id", + "sales_channel_id", + "currency_code", + "region_id", + "shipping_address.city", + "shipping_address.country_code", + "shipping_address.province", + "shipping_address.postal_code", + "items.*", + "item_total", + "total", + ], + options: { throwIfKeyNotFound: true }, + }).config({ name: "get-cart" }) + + const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) + + validatePresenceOfStep({ + entity: cart, + fields: ["sales_channel_id", "region_id", "currency_code"], + }) + + const scFulfillmentSetQuery = useQueryGraphStep({ + entity: "sales_channels", + filters: { id: cart.sales_channel_id }, + fields: [ + "stock_locations.id", + "stock_locations.name", + "stock_locations.address.*", + "stock_locations.fulfillment_sets.id", + ], + }).config({ name: "sales_channels-fulfillment-query" }) + + const scFulfillmentSets = transform( + { scFulfillmentSetQuery }, + ({ scFulfillmentSetQuery }) => scFulfillmentSetQuery.data[0] + ) + + const { fulfillmentSetIds, fulfillmentSetLocationMap } = transform( + { scFulfillmentSets }, + ({ scFulfillmentSets }) => { + const fulfillmentSetIds = new Set() + const fulfillmentSetLocationMap = {} + + scFulfillmentSets.stock_locations.forEach((stockLocation) => { + stockLocation.fulfillment_sets.forEach((fulfillmentSet) => { + fulfillmentSetLocationMap[fulfillmentSet.id] = stockLocation + fulfillmentSetIds.add(fulfillmentSet.id) + }) + }) + + return { + fulfillmentSetIds: Array.from(fulfillmentSetIds), + fulfillmentSetLocationMap, + } + } + ) + + const commonOptions = transform( + { input, cart, fulfillmentSetIds }, + ({ input, cart, fulfillmentSetIds }) => ({ + context: { + is_return: input.is_return ?? false, + enabled_in_store: input.enabled_in_store ?? true, + }, + + filters: { + fulfillment_set_id: fulfillmentSetIds, + + address: { + country_code: cart.shipping_address?.country_code, + province_code: cart.shipping_address?.province, + city: cart.shipping_address?.city, + postal_expression: cart.shipping_address?.postal_code, + }, + }, + }) + ) + + const typeQueryFilters = transform( + { optionIds, commonOptions }, + ({ optionIds, commonOptions }) => ({ + id: optionIds.length ? optionIds : undefined, + ...commonOptions, + }) + ) + + /** + * We need to prefetch exact same SO as in the final result but only to determine pricing calculations first. + */ + const initialOptions = useRemoteQueryStep({ + entry_point: "shipping_options", + variables: typeQueryFilters, + fields: ["id", "price_type"], + }).config({ name: "shipping-options-price-type-query" }) + + /** + * Prepare queries for flat rate and calculated shipping options since price calculations are different for each. + */ + const { flatRateOptionsQuery, calculatedShippingOptionsQuery } = transform( + { + cart, + initialOptions, + commonOptions, + }, + ({ cart, initialOptions, commonOptions }) => { + const flatRateShippingOptionIds: string[] = [] + const calculatedShippingOptionIds: string[] = [] + + initialOptions.forEach((option) => { + if (option.price_type === ShippingOptionPriceType.FLAT) { + flatRateShippingOptionIds.push(option.id) + } else { + calculatedShippingOptionIds.push(option.id) + } + }) + + return { + flatRateOptionsQuery: { + ...commonOptions, + id: flatRateShippingOptionIds, + calculated_price: { context: cart }, + }, + calculatedShippingOptionsQuery: { + ...commonOptions, + id: calculatedShippingOptionIds, + }, + } + } + ) + + const [shippingOptionsFlatRate, shippingOptionsCalculated] = parallelize( + useRemoteQueryStep({ + entry_point: "shipping_options", + fields: [ + ...COMMON_OPTIONS_FIELDS, + "calculated_price.*", + "prices.*", + "prices.price_rules.*", + ], + variables: flatRateOptionsQuery, + }).config({ name: "shipping-options-query-flat-rate" }), + useRemoteQueryStep({ + entry_point: "shipping_options", + fields: [...COMMON_OPTIONS_FIELDS], + variables: calculatedShippingOptionsQuery, + }).config({ name: "shipping-options-query-calculated" }) + ) + + const calculateShippingOptionsPricesData = transform( + { + shippingOptionsCalculated, + cart, + input, + fulfillmentSetLocationMap, + }, + ({ + shippingOptionsCalculated, + cart, + input, + fulfillmentSetLocationMap, + }) => { + const optionDataMap = new Map( + (input.options ?? []).map(({ id, data }) => [id, data]) + ) + + return shippingOptionsCalculated.map( + (so) => + ({ + id: so.id as string, + optionData: so.data, + context: { + ...cart, + from_location: + fulfillmentSetLocationMap[so.service_zone.fulfillment_set_id], + }, + data: optionDataMap.get(so.id), + provider_id: so.provider_id, + } as CalculateShippingOptionPriceDTO) + ) + } + ) + + const prices = calculateShippingOptionsPricesStep( + calculateShippingOptionsPricesData + ) + + const shippingOptionsWithPrice = transform( + { shippingOptionsFlatRate, shippingOptionsCalculated, prices }, + ({ shippingOptionsFlatRate, shippingOptionsCalculated, prices }) => { + return [ + ...shippingOptionsFlatRate.map((shippingOption) => { + const price = shippingOption.calculated_price + + return { + ...shippingOption, + amount: price?.calculated_amount, + is_tax_inclusive: !!price?.is_calculated_price_tax_inclusive, + } + }), + ...shippingOptionsCalculated.map((shippingOption, index) => { + return { + ...shippingOption, + amount: prices[index]?.calculated_amount, + is_tax_inclusive: + prices[index]?.is_calculated_price_tax_inclusive, + calculated_price: prices[index], + } + }), + ] + } + ) + + return new WorkflowResponse(shippingOptionsWithPrice) + } +) diff --git a/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts index 9d8850686809c..60d47f22d16fe 100644 --- a/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts +++ b/packages/core/core-flows/src/cart/workflows/refresh-cart-shipping-methods.ts @@ -9,7 +9,7 @@ import { import { useQueryGraphStep } from "../../common" import { removeShippingMethodFromCartStep } from "../steps" import { updateShippingMethodsStep } from "../steps/update-shipping-methods" -import { listShippingOptionsForCartWorkflow } from "./list-shipping-options-for-cart" +import { listShippingOptionsForCartWithPricingWorkflow } from "./list-shipping-options-for-cart-with-pricing" export const refreshCartShippingMethodsWorkflowId = "refresh-cart-shipping-methods" @@ -32,28 +32,33 @@ export const refreshCartShippingMethodsWorkflow = createWorkflow( "shipping_address.country_code", "shipping_address.province", "shipping_methods.shipping_option_id", + "shipping_methods.data", "total", ], options: { throwIfKeyNotFound: true }, }).config({ name: "get-cart" }) const cart = transform({ cartQuery }, ({ cartQuery }) => cartQuery.data[0]) - const shippingOptionIds: string[] = transform({ cart }, ({ cart }) => + const listShippingOptionsInput = transform({ cart }, ({ cart }) => (cart.shipping_methods || []) - .map((shippingMethod) => shippingMethod.shipping_option_id) + .map((shippingMethod) => ({ + id: shippingMethod.shipping_option_id, + data: shippingMethod.data, + })) .filter(Boolean) ) - when({ shippingOptionIds }, ({ shippingOptionIds }) => { - return !!shippingOptionIds?.length + when({ listShippingOptionsInput }, ({ listShippingOptionsInput }) => { + return !!listShippingOptionsInput?.length }).then(() => { - const shippingOptions = listShippingOptionsForCartWorkflow.runAsStep({ - input: { - option_ids: shippingOptionIds, - cart_id: cart.id, - is_return: false, - }, - }) + const shippingOptions = + listShippingOptionsForCartWithPricingWorkflow.runAsStep({ + input: { + options: listShippingOptionsInput, + cart_id: cart.id, + is_return: false, + }, + }) // Creates an object on which shipping methods to remove or update depending // on the validity of the shipping options for the cart 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 e6dc50d55d126..05915ff88eda1 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 @@ -73,9 +73,20 @@ export const validateShippingOptionPricesStep = createStep( } }) - await fulfillmentModuleService.validateShippingOptionsForPriceCalculation( - calculatedOptions as FulfillmentWorkflow.CreateShippingOptionsWorkflowInput[] - ) + const validation = + await fulfillmentModuleService.validateShippingOptionsForPriceCalculation( + calculatedOptions as FulfillmentWorkflow.CreateShippingOptionsWorkflowInput[] + ) + + if (validation.some((v) => !v)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot calcuate pricing for: [${calculatedOptions + .filter((o, i) => !validation[i]) + .map((o) => o.name) + .join(", ")}] shipping option(s).` + ) + } const regionIdSet = new Set() diff --git a/packages/core/core-flows/src/fulfillment/workflows/calculate-shipping-options-prices.ts b/packages/core/core-flows/src/fulfillment/workflows/calculate-shipping-options-prices.ts index e0ff4d36e546b..48e0aab43877f 100644 --- a/packages/core/core-flows/src/fulfillment/workflows/calculate-shipping-options-prices.ts +++ b/packages/core/core-flows/src/fulfillment/workflows/calculate-shipping-options-prices.ts @@ -25,7 +25,7 @@ export const calculateShippingOptionsPricesWorkflow = createWorkflow( const shippingOptionsQuery = useQueryGraphStep({ entity: "shipping_option", filters: { id: ids }, - fields: ["id", "provider_id", "data"], + fields: ["id", "provider_id", "data", "service_zone.fulfillment_set_id"], }).config({ name: "shipping-options-query" }) const cartQuery = useQueryGraphStep({ @@ -34,12 +34,58 @@ export const calculateShippingOptionsPricesWorkflow = createWorkflow( fields: ["id", "items.*", "shipping_address.*"], }).config({ name: "cart-query" }) + const fulfillmentSetId = transform( + { shippingOptionsQuery }, + ({ shippingOptionsQuery }) => + shippingOptionsQuery.data.map( + (so) => so.service_zone.fulfillment_set_id + ) + ) + + const locationFulfillmentSetQuery = useQueryGraphStep({ + entity: "location_fulfillment_set", + filters: { fulfillment_set_id: fulfillmentSetId }, + fields: ["id", "stock_location_id", "fulfillment_set_id"], + }).config({ name: "location-fulfillment-set-query" }) + + const locationIds = transform( + { locationFulfillmentSetQuery }, + ({ locationFulfillmentSetQuery }) => + locationFulfillmentSetQuery.data.map((lfs) => lfs.stock_location_id) + ) + + const locationQuery = useQueryGraphStep({ + entity: "stock_location", + filters: { id: locationIds }, + fields: ["id", "name", "address.*"], + }).config({ name: "location-query" }) + const data = transform( - { shippingOptionsQuery, cartQuery, input }, - ({ shippingOptionsQuery, cartQuery, input }) => { + { + shippingOptionsQuery, + cartQuery, + input, + locationFulfillmentSetQuery, + locationQuery, + }, + ({ + shippingOptionsQuery, + cartQuery, + input, + locationFulfillmentSetQuery, + locationQuery, + }) => { const shippingOptions = shippingOptionsQuery.data const cart = cartQuery.data[0] + const locations = locationQuery.data + const locationFulfillmentSetMap = new Map( + locationFulfillmentSetQuery.data.map((lfs) => [ + lfs.fulfillment_set_id, + lfs.stock_location_id, + ]) + ) + const shippingOptionDataMap = new Map( input.shipping_options.map((so) => [so.id, so.data]) ) @@ -50,7 +96,14 @@ export const calculateShippingOptionsPricesWorkflow = createWorkflow( optionData: shippingOption.data, data: shippingOptionDataMap.get(shippingOption.id) ?? {}, context: { - cart, + ...cart, + from_location: locations.find( + (l) => + l.id === + locationFulfillmentSetMap.get( + shippingOption.service_zone.fulfillment_set_id + ) + ), }, })) } diff --git a/packages/core/types/src/fulfillment/mutations/shipping-option.ts b/packages/core/types/src/fulfillment/mutations/shipping-option.ts index df4517e86d745..bf02f1f4f25f1 100644 --- a/packages/core/types/src/fulfillment/mutations/shipping-option.ts +++ b/packages/core/types/src/fulfillment/mutations/shipping-option.ts @@ -2,6 +2,7 @@ import { CreateShippingOptionTypeDTO } from "./shipping-option-type" import { ShippingOptionPriceType } from "../common" import { CreateShippingOptionRuleDTO } from "./shipping-option-rule" import { CartDTO } from "../../cart" +import { StockLocationDTO } from "../../stock-location" /** * The shipping option to be created. @@ -151,7 +152,8 @@ export interface CalculateShippingOptionPriceDTO { /** * The calculation context needed for the associated fulfillment provider to calculate the price of a shipping option. */ - context: { - cart: Pick - } & Record + context: CartDTO & { from_location?: StockLocationDTO } & Record< + string, + unknown + > } diff --git a/packages/core/types/src/fulfillment/provider.ts b/packages/core/types/src/fulfillment/provider.ts index b55141a4a40fa..9b06c992adfe6 100644 --- a/packages/core/types/src/fulfillment/provider.ts +++ b/packages/core/types/src/fulfillment/provider.ts @@ -1,3 +1,5 @@ +import { CalculateShippingOptionPriceDTO } from "./mutations" + export type FulfillmentOption = { /** * The option's ID. @@ -13,7 +15,7 @@ export type FulfillmentOption = { } export type CalculatedShippingOptionPrice = { - calculated_price: number + calculated_amount: number is_calculated_price_tax_inclusive: boolean } @@ -52,9 +54,9 @@ export interface IFulfillmentProvider { * Calculate the price for the given fulfillment option. */ calculatePrice( - optionData: Record, - data: Record, - context: Record + optionData: CalculateShippingOptionPriceDTO["optionData"], + data: CalculateShippingOptionPriceDTO["data"], + context: CalculateShippingOptionPriceDTO["context"] ): Promise /** * diff --git a/packages/core/types/src/http/shipping-option/store/payloads.ts b/packages/core/types/src/http/shipping-option/store/payloads.ts index 63815113b909f..8d604852d380a 100644 --- a/packages/core/types/src/http/shipping-option/store/payloads.ts +++ b/packages/core/types/src/http/shipping-option/store/payloads.ts @@ -1,4 +1,4 @@ export type StoreCalculateShippingOptionPrice = { cart_id: string - data: Record + data?: Record } diff --git a/packages/core/types/src/workflow/fulfillment/calculate-shipping-options-prices.ts b/packages/core/types/src/workflow/fulfillment/calculate-shipping-options-prices.ts index 92c5d1a5d87e0..a1597ec0e1577 100644 --- a/packages/core/types/src/workflow/fulfillment/calculate-shipping-options-prices.ts +++ b/packages/core/types/src/workflow/fulfillment/calculate-shipping-options-prices.ts @@ -2,7 +2,7 @@ import { CalculatedShippingOptionPrice } from "../../fulfillment" export type CalculateShippingOptionsPricesWorkflowInput = { cart_id: string - shipping_options: { id: string; data: Record }[] + shipping_options: { id: string; data?: Record }[] } export type CalculateShippingOptionsPricesWorkflowOutput = diff --git a/packages/core/utils/src/fulfillment/provider.ts b/packages/core/utils/src/fulfillment/provider.ts index 12cfff438cc73..7d152431ec507 100644 --- a/packages/core/utils/src/fulfillment/provider.ts +++ b/packages/core/utils/src/fulfillment/provider.ts @@ -1,5 +1,6 @@ import { CalculatedShippingOptionPrice, + CalculateShippingOptionPriceDTO, FulfillmentOption, IFulfillmentProvider, } from "@medusajs/types" @@ -219,9 +220,9 @@ export class AbstractFulfillmentProviderService * } */ async calculatePrice( - optionData: Record, - data: Record, - context: Record + optionData: CalculateShippingOptionPriceDTO["optionData"], + data: CalculateShippingOptionPriceDTO["data"], + context: CalculateShippingOptionPriceDTO["context"] ): Promise { throw Error("calculatePrice must be overridden by the child class") } diff --git a/packages/medusa/src/api/store/shipping-options/[id]/calculate/route.ts b/packages/medusa/src/api/store/shipping-options/[id]/calculate/route.ts index a3ffda5ec8534..50a4420d722de 100644 --- a/packages/medusa/src/api/store/shipping-options/[id]/calculate/route.ts +++ b/packages/medusa/src/api/store/shipping-options/[id]/calculate/route.ts @@ -27,5 +27,11 @@ export const POST = async ( const shippingOption = data[0] const priceData = result[0] - res.status(200).json({ shipping_option: { ...shippingOption, ...priceData } }) + shippingOption.calculated_price = priceData + + // ensure same shape as flat rate shipping options + shippingOption.amount = priceData.calculated_amount + shippingOption.is_tax_inclusive = priceData.is_calculated_price_tax_inclusive + + res.status(200).json({ shipping_option: shippingOption }) } diff --git a/packages/medusa/src/api/store/shipping-options/validators.ts b/packages/medusa/src/api/store/shipping-options/validators.ts index 3cf7a938f0b64..0267919c0258e 100644 --- a/packages/medusa/src/api/store/shipping-options/validators.ts +++ b/packages/medusa/src/api/store/shipping-options/validators.ts @@ -26,5 +26,5 @@ export type StoreCalculateShippingOptionPriceType = z.infer< > export const StoreCalculateShippingOptionPrice = z.object({ cart_id: z.string(), - data: z.record(z.string(), z.unknown()), + data: z.record(z.string(), z.unknown()).optional(), }) diff --git a/packages/modules/fulfillment/src/services/fulfillment-provider.ts b/packages/modules/fulfillment/src/services/fulfillment-provider.ts index 16876730095d8..ea205a95f75e1 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-provider.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-provider.ts @@ -1,4 +1,5 @@ import { + CalculateShippingOptionPriceDTO, Constructor, DAL, FulfillmentTypes, @@ -107,9 +108,9 @@ export default class FulfillmentProviderService extends ModulesSdkUtils.MedusaIn async calculatePrice( providerId: string, - optionData: Record, - data: Record, - context: Record + optionData: CalculateShippingOptionPriceDTO["optionData"], + data: CalculateShippingOptionPriceDTO["data"], + context: CalculateShippingOptionPriceDTO["context"] ) { const provider = this.retrieveProviderRegistration(providerId) return await provider.calculatePrice(optionData, data, context)