diff --git a/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts b/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts index 478e155651..6e3e61411f 100644 --- a/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts +++ b/integration-tests/http/__tests__/shipping-option/admin/shipping-option.spec.ts @@ -1,5 +1,5 @@ -import { RuleOperator } from "@medusajs/utils" import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { RuleOperator } from "@medusajs/utils" import { adminHeaders, createAdminUser, @@ -110,9 +110,7 @@ medusaIntegrationTestRunner({ prices: [{ currency_code: "usd", amount: 1000 }], } - const { - data: { shipping_option: shippingOption }, - } = await api.post( + await api.post( `/admin/shipping-options`, shippingOptionPayload, adminHeaders @@ -147,7 +145,25 @@ medusaIntegrationTestRunner({ description: "Test description", code: "test-code", }, - prices: [{ currency_code: "usd", amount: 1000 }], + prices: [ + { currency_code: "usd", amount: 1000 }, + { + currency_code: "usd", + amount: 500, + rules: [ + { + attribute: "cart_total", + operator: "gte", + value: 100, + }, + { + attribute: "cart_total", + operator: "lte", + value: 200, + }, + ], + }, + ], } const { @@ -167,7 +183,7 @@ medusaIntegrationTestRunner({ id: expect.any(String), name: "Test shipping option", price_type: "flat", - prices: [ + prices: expect.arrayContaining([ { id: expect.any(String), amount: 1000, @@ -185,8 +201,39 @@ medusaIntegrationTestRunner({ created_at: expect.any(String), updated_at: expect.any(String), deleted_at: null, + price_rules: [], }, - ], + { + id: expect.any(String), + amount: 500, + currency_code: "usd", + max_quantity: null, + min_quantity: null, + price_list: null, + price_set_id: expect.any(String), + raw_amount: { + precision: 20, + value: "500", + }, + rules_count: 2, + price_rules: expect.arrayContaining([ + expect.objectContaining({ + attribute: "cart_total", + operator: "gte", + value: "100", + }), + expect.objectContaining({ + attribute: "cart_total", + operator: "lte", + value: "200", + }), + ]), + title: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + ]), provider_id: "manual_test-provider", provider: { id: "manual_test-provider", @@ -275,6 +322,17 @@ medusaIntegrationTestRunner({ region_id: region.id, amount: 1000, }, + { + region_id: region.id, + amount: 500, + rules: [ + { + attribute: "cart_total", + operator: "gt", + value: 200, + }, + ], + }, ], rules: [shippingOptionRule], } @@ -313,6 +371,24 @@ medusaIntegrationTestRunner({ currency_code: "eur", amount: 1000, }), + expect.objectContaining({ + id: expect.any(String), + currency_code: "eur", + amount: 500, + rules_count: 2, + price_rules: expect.arrayContaining([ + expect.objectContaining({ + attribute: "cart_total", + operator: "gt", + value: "200", + }), + expect.objectContaining({ + attribute: "region_id", + operator: "eq", + value: region.id, + }), + ]), + }), ]), rules: expect.arrayContaining([ expect.objectContaining({ @@ -326,6 +402,120 @@ medusaIntegrationTestRunner({ ) }) + it("should throw error when creating a price rule with a non white listed attribute", async () => { + const shippingOptionPayload = { + name: "Test 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: 500, + rules: [ + { + attribute: "not_whitelisted", + operator: "gte", + value: 100, + }, + ], + }, + ], + } + + const error = await api + .post( + `/admin/shipping-options`, + shippingOptionPayload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + }) + + it("should throw error when creating a price rule with a non white listed operator", async () => { + const shippingOptionPayload = { + name: "Test 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: 500, + rules: [ + { + attribute: "cart_total", + operator: "not_whitelisted", + value: 100, + }, + ], + }, + ], + } + + const error = await api + .post( + `/admin/shipping-options`, + shippingOptionPayload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + }) + + it("should throw error when creating a price rule with a string value", async () => { + const shippingOptionPayload = { + name: "Test 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: 500, + rules: [ + { + attribute: "cart_total", + operator: "gt", + value: "string", + }, + ], + }, + ], + } + + const error = await api + .post( + `/admin/shipping-options`, + shippingOptionPayload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + }) + it("should throw error when provider does not exist on a location", async () => { const shippingOptionPayload = { name: "Test shipping option", @@ -431,6 +621,17 @@ medusaIntegrationTestRunner({ id: eurPrice.id, amount: 10000, }, + { + currency_code: "dkk", + amount: 5, + rules: [ + { + attribute: "cart_total", + operator: "gt", + value: 200, + }, + ], + }, ], rules: [ { @@ -463,7 +664,7 @@ medusaIntegrationTestRunner({ ) expect(updateResponse.status).toEqual(200) - expect(updateResponse.data.shipping_option.prices).toHaveLength(2) + expect(updateResponse.data.shipping_option.prices).toHaveLength(3) expect(updateResponse.data.shipping_option.rules).toHaveLength(3) expect(updateResponse.data.shipping_option).toEqual( expect.objectContaining({ @@ -494,6 +695,19 @@ medusaIntegrationTestRunner({ rules_count: 1, amount: 10000, }), + expect.objectContaining({ + id: expect.any(String), + currency_code: "dkk", + rules_count: 1, + amount: 5, + price_rules: [ + expect.objectContaining({ + attribute: "cart_total", + operator: "gt", + value: "200", + }), + ], + }), ]), rules: expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/core/core-flows/src/fulfillment/steps/add-shipping-options-prices.ts b/packages/core/core-flows/src/fulfillment/steps/add-shipping-options-prices.ts index e9450615b6..202691d773 100644 --- a/packages/core/core-flows/src/fulfillment/steps/add-shipping-options-prices.ts +++ b/packages/core/core-flows/src/fulfillment/steps/add-shipping-options-prices.ts @@ -1,19 +1,23 @@ import { CreatePriceSetDTO, + CreatePriceSetPriceRules, IPricingModuleService, IRegionModuleService, + PriceRule, } from "@medusajs/framework/types" -import { Modules } from "@medusajs/framework/utils" -import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { isString, Modules } from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" export interface ShippingOptionsPriceCurrencyCode { currency_code: string amount: number + rules?: PriceRule[] } interface ShippingOptionsPriceRegionId { region_id: string amount: number + rules?: PriceRule[] } export type CreateShippingOptionsPriceSetsStepInput = { @@ -21,15 +25,36 @@ export type CreateShippingOptionsPriceSetsStepInput = { prices: (ShippingOptionsPriceCurrencyCode | ShippingOptionsPriceRegionId)[] }[] -function buildPriceSet( +export function buildPriceSet( prices: CreateShippingOptionsPriceSetsStepInput[0]["prices"], regionToCurrencyMap: Map ): CreatePriceSetDTO { const shippingOptionPrices = prices.map((price) => { + const { rules = [] } = price + const additionalRules: CreatePriceSetPriceRules = {} + + for (const rule of rules) { + let existingPriceRules = additionalRules[rule.attribute] + + if (isString(existingPriceRules)) { + continue + } + + existingPriceRules ||= [] + + existingPriceRules.push({ + operator: rule.operator, + value: rule.value, + }) + + additionalRules[rule.attribute] = existingPriceRules + } + if ("currency_code" in price) { return { currency_code: price.currency_code, amount: price.amount, + rules: additionalRules, } } @@ -38,6 +63,7 @@ function buildPriceSet( amount: price.amount, rules: { region_id: price.region_id, + ...additionalRules, }, } }) diff --git a/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts b/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts index f0f0836c8a..9aad020925 100644 --- a/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts +++ b/packages/core/core-flows/src/fulfillment/steps/set-shipping-options-prices.ts @@ -1,5 +1,6 @@ import { CreatePriceDTO, + CreatePriceSetPriceRules, CreatePricesDTO, FulfillmentWorkflow, IPricingModuleService, @@ -13,6 +14,7 @@ import { LINKS, Modules, isDefined, + isString, } from "@medusajs/framework/utils" import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" @@ -64,6 +66,26 @@ function buildPrices( } const shippingOptionPrices = prices.map((price) => { + const { rules = [] } = price + const additionalRules: CreatePriceSetPriceRules = {} + + for (const rule of rules) { + let existingPriceRules = additionalRules[rule.attribute] + + if (isString(existingPriceRules)) { + continue + } + + existingPriceRules ||= [] + + existingPriceRules.push({ + operator: rule.operator, + value: rule.value, + }) + + additionalRules[rule.attribute] = existingPriceRules + } + if ("region_id" in price) { const currency_code = regionToCurrencyMap.get(price.region_id!)! const regionId = price.region_id @@ -74,6 +96,17 @@ function buildPrices( amount: price.amount, rules: { region_id: regionId, + ...additionalRules, + }, + } + } + + if ("currency_code" in price) { + return { + ...price, + amount: price.amount, + rules: { + ...additionalRules, }, } } @@ -142,8 +175,10 @@ export const setShippingOptionsPricesStep = createStep( price_set_id: currentShippingOptionDataItem.price_set_id, } }) + const buildPricesData = pricesData && buildPrices(pricesData, regionToCurrencyMap) + return [ currentShippingOptionDataItem.shipping_option_id, { diff --git a/packages/core/types/src/pricing/common/price-rule.ts b/packages/core/types/src/pricing/common/price-rule.ts index 49a33bbd7c..c59a0b4bcf 100644 --- a/packages/core/types/src/pricing/common/price-rule.ts +++ b/packages/core/types/src/pricing/common/price-rule.ts @@ -138,3 +138,9 @@ export interface FilterablePriceRuleProps * The possible operators to use in a price rule. */ export type PricingRuleOperatorValues = "gt" | "lt" | "eq" | "lte" | "gte" + +export interface PriceRule { + attribute: string + operator: PricingRuleOperatorValues + value: number +} diff --git a/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts b/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts index 6718ced15b..4e1ad086a7 100644 --- a/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts +++ b/packages/core/types/src/workflow/fulfillment/update-shipping-options.ts @@ -1,5 +1,6 @@ -import { ShippingOptionPriceType } from "../../fulfillment" import { RuleOperatorType } from "../../common" +import { ShippingOptionPriceType } from "../../fulfillment" +import { PriceRule } from "../../pricing" export interface UpdateShippingOptionsWorkflowInput { id: string @@ -19,11 +20,13 @@ export interface UpdateShippingOptionsWorkflowInput { id?: string currency_code?: string amount?: number + rules?: PriceRule[] } | { id?: string region_id?: string amount?: number + rules?: PriceRule[] } )[] rules?: { diff --git a/packages/medusa/src/api/admin/shipping-options/query-config.ts b/packages/medusa/src/api/admin/shipping-options/query-config.ts index 9f69713dfb..13addf5552 100644 --- a/packages/medusa/src/api/admin/shipping-options/query-config.ts +++ b/packages/medusa/src/api/admin/shipping-options/query-config.ts @@ -10,6 +10,7 @@ export const defaultAdminShippingOptionFields = [ "*rules", "*type", "*prices", + "*prices.price_rules", "*service_zone", "*shipping_profile", "*provider", diff --git a/packages/medusa/src/api/admin/shipping-options/validators.ts b/packages/medusa/src/api/admin/shipping-options/validators.ts index 1eb97ecb4e..27225c25a4 100644 --- a/packages/medusa/src/api/admin/shipping-options/validators.ts +++ b/packages/medusa/src/api/admin/shipping-options/validators.ts @@ -1,4 +1,5 @@ import { + PricingRuleOperator, RuleOperator, ShippingOptionPriceType as ShippingOptionPriceTypeEnum, } from "@medusajs/framework/utils" @@ -83,11 +84,20 @@ export const AdminCreateShippingOptionTypeObject = z }) .strict() +const AdminPriceRules = z.array( + z.object({ + attribute: z.literal("cart_total"), + operator: z.nativeEnum(PricingRuleOperator), + value: z.number(), + }) +) + // eslint-disable-next-line max-len export const AdminCreateShippingOptionPriceWithCurrency = z .object({ currency_code: z.string(), amount: z.number(), + rules: AdminPriceRules.optional(), }) .strict() @@ -95,6 +105,7 @@ export const AdminCreateShippingOptionPriceWithRegion = z .object({ region_id: z.string(), amount: z.number(), + rules: AdminPriceRules.optional(), }) .strict() @@ -103,6 +114,7 @@ export const AdminUpdateShippingOptionPriceWithCurrency = z id: z.string().optional(), currency_code: z.string().optional(), amount: z.number().optional(), + rules: AdminPriceRules.optional(), }) .strict() @@ -111,6 +123,7 @@ export const AdminUpdateShippingOptionPriceWithRegion = z id: z.string().optional(), region_id: z.string().optional(), amount: z.number().optional(), + rules: AdminPriceRules.optional(), }) .strict()