fix(fulfillment): Geozone constraints builder (#13282)

This commit is contained in:
Oli Juhl
2025-08-23 14:51:04 +02:00
committed by GitHub
parent 486621383a
commit f0ef444992
2 changed files with 867 additions and 21 deletions

View File

@@ -1324,6 +1324,835 @@ moduleIntegrationTestRunner<IFulfillmentModuleService>({
})
})
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({

View File

@@ -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<FulfillmentTypes.ShippingOptionTypeDTO[] | FulfillmentTypes.ShippingOptionTypeDTO> {
): 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<string, string | undefined>)
return {
type,
...props.reduce((geoZoneConstraint, prop) => {
if (isPresent(address![prop])) {
geoZoneConstraint[prop] = address![prop]
}
return geoZoneConstraint
}, {} as Record<string, string | undefined>),
}
}
return null
})
.filter((v): v is Record<string, any> => !!v)
.filter((v) => !!v)
return geoZoneConstraints
}