diff --git a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts index d10c2415c3..ca93c76490 100644 --- a/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts +++ b/packages/modules/fulfillment/integration-tests/__tests__/fulfillment-module-service/shipping-option.spec.ts @@ -1324,6 +1324,835 @@ moduleIntegrationTestRunner({ }) }) + describe("buildGeoZoneConstraintsFromAddress", () => { + it("should build correct constraints for full address with postal expression", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + { + type: GeoZoneType.CITY, + country_code: "US", + province_code: "CA", + city: "San Francisco", + }, + { + type: GeoZoneType.PROVINCE, + country_code: "US", + province_code: "NY", + }, + { + type: GeoZoneType.COUNTRY, + country_code: "CA", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test with full address including postal expression + const shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + + expect(shippingOptions).toHaveLength(1) + }) + + it("should build correct constraints for address with city but no postal expression", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.CITY, + country_code: "US", + province_code: "CA", + city: "San Francisco", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test with city but no postal expression + const shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "San Francisco", + }, + }) + + expect(shippingOptions).toHaveLength(1) + }) + + it("should build correct constraints for address with province but no city", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.PROVINCE, + country_code: "US", + province_code: "NY", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test with province but no city + const shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "NY", + }, + }) + + expect(shippingOptions).toHaveLength(1) + }) + + it("should build correct constraints for address with only country", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.COUNTRY, + country_code: "CA", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test with only country + const shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + }, + }) + + expect(shippingOptions).toHaveLength(1) + }) + + it("should handle hierarchical geo zone matching correctly", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + { + type: GeoZoneType.CITY, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + }, + { + type: GeoZoneType.PROVINCE, + country_code: "US", + province_code: "CA", + }, + { + type: GeoZoneType.COUNTRY, + country_code: "US", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test with full address - should match all levels + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(1) + + // Test with partial address - should still match broader zones + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + }, + }) + expect(shippingOptions).toHaveLength(1) + + // Test with only country - should match country zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + }, + }) + expect(shippingOptions).toHaveLength(1) + }) + + it("should not match zones when address doesn't satisfy requirements", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Wrong postal code - should not match + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90211", + }, + }) + expect(shippingOptions).toHaveLength(0) + + // Wrong city - should not match + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "San Francisco", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(0) + + // Wrong province - should not match + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "NY", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(0) + + // Wrong country - should not match + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(0) + }) + + it("should handle partial address matching correctly", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.PROVINCE, + country_code: "US", + province_code: "CA", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Full address with matching province should match + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Any City", + postal_expression: "12345", + }, + }) + expect(shippingOptions).toHaveLength(1) + + // Minimal matching address should match + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + }, + }) + expect(shippingOptions).toHaveLength(1) + + // Address with only country should not match province-level zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + }, + }) + expect(shippingOptions).toHaveLength(0) + }) + + it("should handle empty or null address fields correctly", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "test", + geo_zones: [ + { + type: GeoZoneType.COUNTRY, + country_code: "US", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + await service.createShippingOptions([ + generateCreateShippingOptionsData({ + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Address with undefined fields should still match if country matches + const shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: undefined, + city: undefined, + postal_expression: undefined, + }, + }) + expect(shippingOptions).toHaveLength(1) + }) + + it("should correctly match addresses across multiple service zones", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "US Zones", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + { + type: GeoZoneType.CITY, + country_code: "US", + province_code: "NY", + city: "New York", + }, + ], + }, + { + name: "Europe Zones", + geo_zones: [ + { + type: GeoZoneType.COUNTRY, + country_code: "FR", + }, + { + type: GeoZoneType.PROVINCE, + country_code: "DE", + province_code: "BY", + }, + ], + }, + { + name: "Canada Zones", + geo_zones: [ + { + type: GeoZoneType.PROVINCE, + country_code: "CA", + province_code: "ON", + }, + { + type: GeoZoneType.CITY, + country_code: "CA", + province_code: "BC", + city: "Vancouver", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + // Create shipping options for each zone + const [usOption, europeOption, canadaOption] = await service.createShippingOptions([ + generateCreateShippingOptionsData({ + name: "US Shipping", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + generateCreateShippingOptionsData({ + name: "Europe Shipping", + service_zone_id: fulfillmentSet.service_zones[1].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + generateCreateShippingOptionsData({ + name: "Canada Shipping", + service_zone_id: fulfillmentSet.service_zones[2].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test US ZIP code - should only match US zone + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(usOption.id) + + // Test New York city - should only match US zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "NY", + city: "New York", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(usOption.id) + + // Test France - should only match Europe zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "FR", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(europeOption.id) + + // Test German province - should only match Europe zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "DE", + province_code: "BY", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(europeOption.id) + + // Test Canadian province - should only match Canada zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + province_code: "ON", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(canadaOption.id) + + // Test Vancouver - should only match Canada zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + province_code: "BC", + city: "Vancouver", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(canadaOption.id) + + // Test non-matching address - should return no options + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "JP", + city: "Tokyo", + }, + }) + expect(shippingOptions).toHaveLength(0) + }) + + it("should handle overlapping zones across multiple service zones", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "Broad US Zone", + geo_zones: [ + { + type: GeoZoneType.COUNTRY, + country_code: "US", + }, + ], + }, + { + name: "Specific California Zone", + geo_zones: [ + { + type: GeoZoneType.PROVINCE, + country_code: "US", + province_code: "CA", + }, + ], + }, + { + name: "Specific LA Zone", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + // Create shipping options with different prices for each zone + const [broadOption, californiaOption, laOption] = await service.createShippingOptions([ + generateCreateShippingOptionsData({ + name: "Standard US Shipping", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + generateCreateShippingOptionsData({ + name: "California Express", + service_zone_id: fulfillmentSet.service_zones[1].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + generateCreateShippingOptionsData({ + name: "LA Premium", + service_zone_id: fulfillmentSet.service_zones[2].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test LA ZIP code - should match all three zones + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(3) + const laIds = shippingOptions.map(opt => opt.id) + expect(laIds).toContain(broadOption.id) + expect(laIds).toContain(californiaOption.id) + expect(laIds).toContain(laOption.id) + + // Test California (not LA) - should match broad US and California zones + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "San Francisco", + }, + }) + expect(shippingOptions).toHaveLength(2) + const caIds = shippingOptions.map(opt => opt.id) + expect(caIds).toContain(broadOption.id) + expect(caIds).toContain(californiaOption.id) + expect(caIds).not.toContain(laOption.id) + + // Test US (not California) - should only match broad US zone + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "NY", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(broadOption.id) + + // Test non-US address - should match nothing + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + }, + }) + expect(shippingOptions).toHaveLength(0) + }) + + it("should handle mixed granularity zones across service zones", async () => { + const fulfillmentSet = await service.createFulfillmentSets({ + name: "test", + type: "test-type", + service_zones: [ + { + name: "Mixed Zone 1", + geo_zones: [ + { + type: GeoZoneType.ZIP, + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + { + type: GeoZoneType.COUNTRY, + country_code: "FR", + }, + ], + }, + { + name: "Mixed Zone 2", + geo_zones: [ + { + type: GeoZoneType.CITY, + country_code: "US", + province_code: "NY", + city: "New York", + }, + { + type: GeoZoneType.PROVINCE, + country_code: "CA", + province_code: "ON", + }, + ], + }, + ], + }) + + const shippingProfile = await service.createShippingProfiles({ + name: "test", + type: "default", + }) + + const [option1, option2] = await service.createShippingOptions([ + generateCreateShippingOptionsData({ + name: "Mixed Option 1", + service_zone_id: fulfillmentSet.service_zones[0].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + generateCreateShippingOptionsData({ + name: "Mixed Option 2", + service_zone_id: fulfillmentSet.service_zones[1].id, + shipping_profile_id: shippingProfile.id, + provider_id: providerId, + }), + ]) + + // Test LA ZIP - should match zone 1 + let shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "CA", + city: "Los Angeles", + postal_expression: "90210", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(option1.id) + + // Test France - should match zone 1 + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "FR", + city: "Paris", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(option1.id) + + // Test New York - should match zone 2 + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "US", + province_code: "NY", + city: "New York", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(option2.id) + + // Test Ontario, Canada - should match zone 2 + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "CA", + province_code: "ON", + city: "Toronto", + }, + }) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0].id).toBe(option2.id) + + // Test unmatched location + shippingOptions = await service.listShippingOptionsForContext({ + address: { + country_code: "UK", + }, + }) + expect(shippingOptions).toHaveLength(0) + }) + }) + describe("on update shipping option rules", () => { it("should update a shipping option rule", async () => { const shippingProfile = await service.createShippingProfiles({ diff --git a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts index 767043a472..651190163c 100644 --- a/packages/modules/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/modules/fulfillment/src/services/fulfillment-module-service.ts @@ -35,6 +35,7 @@ import { ModulesSdkUtils, promiseAll, } from "@medusajs/framework/utils" +import { isObject } from "@medusajs/utils" import { Fulfillment, FulfillmentProvider, @@ -59,7 +60,6 @@ import { joinerConfig } from "../joiner-config" import { UpdateShippingOptionsInput } from "../types/service" import { buildCreatedShippingOptionEvents } from "../utils/events" import FulfillmentProviderService from "./fulfillment-provider" -import { isObject } from "@medusajs/utils" const generateMethodForModels = { FulfillmentSet, @@ -1411,7 +1411,7 @@ export default class FulfillmentModuleService shippingOption.id )! // Guaranteed to exist since the validation above have been performed - if (isObject(shippingOption.type) && !("id" in shippingOption.type)) { + if (isObject(shippingOption.type) && !("id" in shippingOption.type)) { optionTypeDeletedIds.push(existingShippingOption.type.id) } @@ -1696,11 +1696,18 @@ export default class FulfillmentModuleService idOrSelector: string | FulfillmentTypes.FilterableShippingOptionTypeProps, data: FulfillmentTypes.UpdateShippingOptionTypeDTO, @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise< + | FulfillmentTypes.ShippingOptionTypeDTO[] + | FulfillmentTypes.ShippingOptionTypeDTO + > { let normalizedInput: FulfillmentTypes.UpdateShippingOptionTypeDTO[] = [] if (isString(idOrSelector)) { // Check if the type exists in the first place - await this.shippingOptionTypeService_.retrieve(idOrSelector, {}, sharedContext) + await this.shippingOptionTypeService_.retrieve( + idOrSelector, + {}, + sharedContext + ) normalizedInput = [{ id: idOrSelector, ...data }] } else { const types = await this.shippingOptionTypeService_.list( @@ -2486,15 +2493,22 @@ export default class FulfillmentModuleService * Define the hierarchy of required properties for the geo zones. */ const geoZoneRequirePropertyHierarchy = { - postal_expression: [ - "country_code", - "province_code", - "city", - "postal_expression", - ], - city: ["country_code", "province_code", "city"], - province_code: ["country_code", "province_code"], - country_code: ["country_code"], + postal_expression: { + props: ["country_code", "province_code", "city", "postal_expression"], + type: "zip", + }, + city: { + props: ["country_code", "province_code", "city"], + type: "city", + }, + province_code: { + props: ["country_code", "province_code"], + type: "province", + }, + country_code: { + props: ["country_code"], + type: "country", + }, } /** @@ -2504,18 +2518,21 @@ export default class FulfillmentModuleService */ const geoZoneConstraints = Object.entries(geoZoneRequirePropertyHierarchy) - .map(([prop, requiredProps]) => { + .map(([prop, { props, type }]) => { if (address![prop]) { - return requiredProps.reduce((geoZoneConstraint, prop) => { - if (isPresent(address![prop])) { - geoZoneConstraint[prop] = address![prop] - } - return geoZoneConstraint - }, {} as Record) + return { + type, + ...props.reduce((geoZoneConstraint, prop) => { + if (isPresent(address![prop])) { + geoZoneConstraint[prop] = address![prop] + } + return geoZoneConstraint + }, {} as Record), + } } return null }) - .filter((v): v is Record => !!v) + .filter((v) => !!v) return geoZoneConstraints }