Rate Card Manager

The Rate Card Manager (RCM) is a system that allows customers to bring their own rate cards for use in ShipEngine. For a carrier to support this, it needs to use Native Rating and have its rating logic written so that it can handle the pre-defined data and key formats generated by the RCM. After familiarizing yourself with building a Native Rating implementation and building the rating logic, continue on to learn about the details required to support the RCM.

Overview

The Rate Card Manager makes some assumptions about what rate cards look like and how they work. The data is treated a hierarchy.

Rate hierarchy:

Copy
Copied
- Origin location
  - Service
    - Zone
      - Package Type
        - Weight Band
          - Rate

Surcharge hierarchy:

Copy
Copied
- Origin location
  - Service
    - Zone

The Origin location will either be a country code, like GB or US, or a postal code/country code combination, like LS12JS-GB or 63123-US. A rate card may have both origin location types defined and the carrier logic needs to define what to do in this situation. The two most straightforward approaches to dealing with this would be to either use the most specific origin or use whichever would result in the better rate.

Service will be restricted to what is defined in the ShippingServices property of the carrier definition. The value that will be used comes from the Code property of each service.

Copy
Copied
{
  "Carriers": [
    {
      "ShippingServices": [
        {
            "Id": "198cd634-f562-4b5a-ac64-0558f1e0767a",
            "Name": "Overnight Express Service",
            "Code": "OV_1", // <-- This value will be used by the RCM
            "Abbreviation": "OES"
        }
      ]
    }
  ]
}

Zone will be restricted to what is defined in the Zones property of the carrier definition. The value that will be used comes from the ApiCode property of each zone. The zone data provided by the carrier must also use this value so that when a zone lookup is done, the value returned matches what the RCM provides.

Copy
Copied
{
  "Carriers": [
    {
      "Zones": [
        {
          "Name": "United Kingdom",
          "ApiCode": "uk" // <-- This value will be used by the RCM
        }
      ]
    }
  ]
}

Package Type will be restricted to what is defined in the PackageRatingGroups property of the carrier definition. The two important values are Id and CarrierPackageTypeCode. The shipment to be rated will use the CarrierPackageTypeCode and the rating logic should return this value as well. Internally, however, the data provided by the RCM will use Id. This is because there may be multiple package rating groups associated with a single package type.

Copy
Copied
{
  "Carriers": [
    {
      "PackageRatingGroups": [
        {
          "Id": "sm_box", // <-- This value will be used by the RCM
          "Name": "Small Box",
          "PackageTypeId": "a0bd78f2-601b-4f38-b4b7-7a30e3e0d279",
          "CarrierPackageTypeCode": "small-box" // <-- This value is used by the `RateShipments` interface
        }
      ]
    }
  ]
}

Weight Band data is scoped to a service type. The weight unit to be used for each weight band will be included in the data, as will the currency that should be used for all associated rates. The packageTypes property is an object with an entry for each package type that has rates for the given service. The value of each package type entry is an array of weight bands, which will take one of two forms. It can either be a weight range or an incremental weight band. A weight band definition for a given shipment may include both and it is up to the logic to determine what to do in this case. This is common for rate cards that define rates for ranges up to a certain weight and then switch to an incremental rate above that weight. If the applicable weight band is a range, the key value should be used for building a rating key. The range itself will be an array with the maximum value first and an optional minimum value second. If the minimum value is not specified, the maximum value of the previous range should be used as the minimum and if it is the first entry, it means there is no minimum. The maximum value should be considered inclusive while minimum is exclusive. So if there are two ranges [2,1] and [3,2] and the shipment weight is 2, the first band should be used.

Copy
Copied
{
  "currency": "GBP",
  "unit": "kilograms",
  "packageTypes": {
    "sm_box": [
      {
        "type": "range",
        "key": "2kg", // <-- This value should be used to build the rating key
        "range": [2, 1]
      },
    ],
  },
}

Walkthrough

This walkthrough assumes that you've created a Connect Carrier app that includes Native Rating, as described in the implementation documentation. It will focus on the differences between a regular Native Rating implementation and one that supports the Rate Card Manager.

Update the carrier definition

The Rate Card Manager needs some extra data in the carrier definition that is not supplied by default. The definitions of the properties mentioned below are in the carrier metadata definition, but examples will be provided here.

ApiCode

If the carrier ApiCode property is not set, a guid will be used instead. The code is then manually changed later in the deployment process. This is normally fine, but it makes testing with the Rate Card Manager more difficult because the carrier association can be lost when the ApiCode changes. Therefore, it is best to set it before the first publish. If you are unsure what this value should be, please contact a member of the ShipEngine Connect Team for guidance.

PackageRatingGroups

The PackageRatingGroups property defines which package types are available to the Rate Card Manager as well as weight and dimension limits. The PackageTypeId and CarrierPackageTypeCode must point to a valid entry in the PackageTypes property, and multiple PackageRatingGroups may point to the same PackageType. This would allow a carrier to define a single package type for use in ShipStation or ShipEngine, but that could have multiple different weight or dimension limits depending on the service. Users of ShipStation or ShipEngine will get the list defined in PackageTypes but users entering their rate card data will get the list defined in PackageRatingGroups.

Copy
Copied
{
  Carriers: [
  {
    PackageRatingGroups: [
    {
      Id: 'sm_box',
      Name: 'Small Box',
      PackageTypeId: '7cd4bee2-4859-46fa-a191-dcc8a1659ae2',
      CarrierPackageTypeCode: 'small-box',
      DimensionLimits: {
        Unit: DimensionUnit.Centimeters,
        Length: 30,
        Width: 10,
        Height: 10,
      },
      MaxWeight: {
        Unit: WeightUnit.Kilograms,
        Value: 10,
      },
    }]
  }]
}

Zones

The Zones property defines all possible zones available to a carrier. When a user enters their rate card data, they will select their zones from this list. The data that drives what zone should be used for a given shipment will be provided through the normal Native Rating mechanism and can use whatever scheme fits the carrier's needs. The RCM will use the ApiCode from this list when building rating keys.

Copy
Copied
{
  Carriers: [
  {
    Zones: [
    {
      Name: "United Kingdom",
      ApiCode: "uk"
    },
    {
      Name: "Mainland Europe",
      ApiCode: "eu"
    }]
  }]
}

SupportsUserManagedRates

Finally, the NativeRating.SupportsUserManagedRates must be set to true. The RCM uses this value to determine whether a user is allowed to manage their own rates for a carrier.

Copy
Copied
{
  Carriers: [
  {
    NativeRating: {
      SupportsUserManagedRates: true,
      Path: "/path/to/carrier/implementation"
    }
  }]
}

Add Native Rating variable and rate data

This is an optional step because the Rate Card Manager will be providing the data for users' rate cards. However, it can be very helpful when testing to have a rate card already in the system that can be changed quickly and easily. This will also allow you to create a carrier that can provide retail rates by default but let customers override those rates with their own. Even if you don't want a test rate card and don't want to provide retail rates, it is worth reviewing this step as it will provide examples of the data structures that the RCM will provide.

All of the following key definitions will start with a value <origin>. This corresponds to the origin location value specified above.

Services

For each rate card, the RCM will create a list of services included in that rate card. The key format is <origin>-services and the value is an object whose keys are the list of services used in the rate card and whose values are the zones used for that service. The service keys use the Code field of the ShippingService entry. The zones are provided to reduce the data that needs to be retrieved. If a carrier has 30 possible zones, but a user only adds rates for two of them, only those two zones will be included in the list.

Copy
Copied
{
  "<origin>-services": {
    "OV_1": ["uk", "eu"],
    "GND_1": ["uk"]
  }
}

Packages

The RCM will also provide a list of packages used by the rate card. They key format is <origin>-package_types and the value is an object whose keys are the list of packages and whose values are the list of package rating groups associated with those packages. The package keys use the CarrierPackageTypeCode field of the PackageType entry. The values are objects with an id field that corresponds to the Id field of PackageRatingGroup entry. It may also include dimensionLimits that should be used for that package rating group.

Copy
Copied
{
  "GB-package_types": {
    "small-box": [
      {
        "id": "sm_box",
        "dimensionLimits": {
          "unit": "centimeters",
          "length": 30,
          "width": 20,
          "height": 10,
          "girth": 45,
          "noTwoSides": 25,
          "lengthPlusGirth": 60,
          "volume": 100,
        },
        "maxWeight": {
          "unit": "kilograms",
          "value": "10"
        }
      }],
    "large-box": [
      { "id": "lg_box" },
      { "id": "oversize_box" },
    ]
  }
}

Weight Bands

Weight bands are the last variables that the RCM will provide based on the customer rate cards. The key format is <origin>-weight_bands-<service>-<zone>. The value is an object that specifies the weight unit used by the weight bands and a list of weight bands for each package type. The keys of the package type list correspond to the Id property of its corresponding PackageRatingGroup retrieved from the packages variable.

Copy
Copied
{
  "GB-weight_bands-OV_1-uk": {
    "currency": "GBP",
    "unit": "kilograms",
    "packageTypes": {
      "sm_box": [
        { "type": "range", "key": "2kg", "range": [2, 0] },
        { "type": "range", "key": "10kg", "range": [10, 2] }
      ],
      "lg_box": [
        { "type": "range", "key": "10kg", "range": [10, 2] },
        {
          "type": "incremental",
          "increment": 1,
          "amountPerIncrement": 0.95,
          "baseCost": 25.45
        }
      ],
    },
  }
}

Surcharges

Surcharges are optional and may not be supplied by the RCM if users don't enter any or if your carrier does not support them. Surcharges can be defined at the rate card level, service level, or zone level. Surcharges from any or all levels may apply to a given shipment. The key formats are <origin>-surcharges, <origin>-surcharges-<service>, and <origin>-surcharges-<service>-<zone>, which correspond to the various levels of the hierarchy to which the surcharges apply. The value is an array of surcharges that include a code field, an optional fix_amount field for fixed surcharges, and an optional percentage_amount field for percentage based surcharges.

Copy
Copied
{
  "GB-surcharges": [
    { "code": "COD", "fix_amount": 1.95 }
  ],
  "GB-surcharges-OV_1": [
    { "code": "handling", "fix_amount": 3.50 },
    { "code": "fuel surcharge", "percentage_amount": 0.29 }
  ]
}

Rates

This data contains the actual rate values entered by the customer for their rate card. The RCM will provide the data as simple key/value pairs where the key has the format <origin>-<service>-<zone>-<package>-<weight_band_key> and the value is a string representation of a number. When your rating logic gets the rate, it will be as an actual number value but if you provide the rates for a test rate card, it must be as a string.

Copy
Copied
{
  { "key": "GB-OV_1-uk-sm_box-10kg", "value": "5.99" },
  { "key": "GB-OV_1-uk-lg_box-20kg", "value": "9.49" },
  { "key": "GB-OV_1-uk-sm_box-2kg", "value": "4.49" },
  { "key": "GB-OV_1-eu-sm_box-10kg", "value": "4.99" },
  { "key": "GB-OV_1-eu-sm_box-2kg", "value": "3.49" },
  { "key": "GB-GND_1-eu-sm_box-10kg", "value": "6.95" },
  { "key": "GB-GND_1-eu-sm_box-2kg", "value": "5.25" }
}

Add Native Rating zone data

The Rate Card Manager needs zones for rating but how your rating logic deals with that is up to you. If your carrier doesn't use zones, then you can create a single zone in the carrier definition and just hard code that key in your logic. If your zone scheme is simple, like domestic if the country codes match and international if they don't, your logic can take care of that and you can skip adding Native Rating zone data. Most likely, you will need to make use of the zone data lookup provided by Native Rating and it would be worth spending some time deciding on how that will work. The getZone function provided by Native Rating can only look up full keys, so you can't do any "starts with" or "contains" queries.

Designing the best scheme for your zone data is beyond the scope of this document, but we'll discuss a few common scenarios.

If your zones are based on origin and destination postal code, it can be impractical to store every combination of all postal codes. Instead, it may be possible to store all combinations of the first few characters of the postal code and then store exceptions using the full postal code. Doing this means that there is much less data to store, but it means you'll need to request more zone keys. So instead of searching for 90210-10001, you'd need to search for 902-100, 902-10001, 90210-100, and 90210-10001. Then you can use the most specific result as the zone.

Another option would be to store lookup information in the zone value itself. Native Rating does not impose a data structure on the zone value so you could create a zone where the key is US and the value is { "east": [0,50000], "west": [50001,99999] }. Then you would request the US zone and when you get the result, iterate through the keys and check whether the destination postal code is within the range. This works well for simple scenarios like this where the ranges can include large numbers of postal codes, but it's much less suited to situations where the zone data would contain a large amount of data.

Create Native Rating logic

The rating logic for carriers that support the RCM should follow this basic flow:

  1. Fetch the services, packages, and zone for the shipment
  2. Fetch the weight band data based on this information
  3. Find the applicable weight band(s)
  4. Fetch rates and surcharges
  5. Return the list of applicable rates and surcharges

The logic can do more than this, like validating data, but that is outside the scope of this document. This will just focus on the most basic process for creating rates from a customer supplied rate card. When providing code examples, the following shipment will be used:

Copy
Copied
{
  "ship_from": {
    "postal_code": "LS1 2JS",
    "country_code": "GB"
  },
  "ship_to": {
    "postal_code": "SL1 3QG",
    "country_code": "GB"
  },
  "packages": [{
    "weight_details": {
      "weight_in_grams": 5000,
      "weight_in_ounces": 176.37,
      "source_weight": 5000,
      "source_weight_unit": "grams"
    },
    "dimension_details": {
      "dimensions_in_centimeters": { "length": 32, "width": 10, "height": 5 },
      "dimensions_in_inches": { "length": 12.6, "width": 3.94, "height": 1.97 },
      "source_dimensions": { "length": 32, "width": 10, "height": 5 },
      "source_unit": "centimeters"
    }
  }]
}

Fetch the services, packages, and zone

The first step is to request the preliminary data needed by the rest of the logic. Notice that the services and package_types keys are requested twice: once with the postalCode-country prefix and once with just country. This is because rate cards can be created using either as the origin, and both must be accounted for. You must keep track of which origin data came from because data from a key with the postalCode-country prefix would be from a different rate card than data from a key with the country prefix. This will apply to all data fetches.

Copy
Copied
const [ variableData, zoneData ] = await Promise.all([
  context.getVariables([
    "LS12JS-GB-services",
    "LS12JS-GB-package_types",
    "GB-services",
    "GB-package_types"
  ]),
  context.getZone(["GB-GB"])
]);

If there are no services or packages returned, it should be treated as an error condition. However, it is very likely that services and packages will only be returned for a single origin prefix.

Copy
Copied
if (
  (variableData["LS12JS-GB-services"] && variableData["LS12JS-GB-package_types"]) ||
  (variableData["GB-services"] && variableData["GB-package_types"])) {
    // We have services and packages for at least one origin prefix
} else {
  throw new Error("Could not find data for rate card")
}

Zones are not rate card specific so as long as something is returned, it should be treated as a success.

Copy
Copied
if (!zoneData["GB-GB"]) {
  throw new Error("Could not find zone");
}

Once services, packages, and zones are retrieved, the logic can move on to the next step. For the example code, we will assume that there is a valid rate card with the country origin prefix but not the postalCode-country prefix. This will make the examples simpler, but remember that it is possible for data to be returned for both and that needs to be handled.

Fetch the weight band data

Now we need to fetch the weight band data for the rate card. We will assume the following was returned from the previous step:

Copy
Copied
const variableData = {
  // Services
  "GB-services": [{
    "OV_1": ["uk", "eu"],
    "GND_1": ["uk"],
    "INT-1": ["eu"]
  }],
  // Packages
  "GB-package_types": {
    "sm_box": [  // <-- This corresponds to the `CarrierPackageTypeCode` property of the PackageRatingGroup entry
      {
        id: "small-box", // <-- This corresponds to the `Id` property of the PackageRatingGroup entry
        dimensionLimits: {
          unit: "centimeters",
          length: 30
        }
      }],
    "lg_box": [
      {
        id: "large-box",
        maxWeight: {
          unit: "kilograms",
          value: "10"
        }
      },
      { id: "oversize-box" },
    ]
  }
}

const zoneData = {
  // Zone
  "GB-GB": "uk"
}

When getting the weight bands, we can use the service list to filter what we need to request. You can see that the INT-1 service only applies to the eu zone, but this shipment is in the uk zone. So we can skip getting weight band data for that service.

Copy
Copied
const weightBands = await context.getVariables([
  "GB-weight_bands-OV_1-uk",
  "GB-weight_bands-GND_1-uk"
])

Just like before, if at least one set of weight bands is not returned, it should be treated as an error condition.

Find the applicable weight band(s)

Once we have the weight bands, we have all the data needed to finally get rates. Before we do that, however, we need to figure out which rate keys to fetch. In this example, we'll assume that we got the following weight band data:

Copy
Copied
const weightBands = {
  "GB-weight_bands-GND_1-uk": {
    currency: "GBP",
    unit: "kilograms",
    packageTypes: {
      "sm_box": [
        { type: "range", key: "2kg", range: [2, 0] },
        { type: "range", key: "10kg", range: [10, 2] }
      ],
      "lg_box": [
        { type: "range", key: "10kg", range: [10, 2] },
        {
          type: "incremental",
          increment: 2,
          amountPerIncrement: 0.95,
          baseCost: 25.45
        }
      ],
    },
  }
}

It is normal that even through two keys were requested, data for only one was returned. Your logic should handle that scenario. Since no weight bands were returned for the OV-1 service, it can be assumed that the service does not apply to this shipment. The logic should use the package types to find which bands fit the shipment. The package data fetched in the first step can be used to filter out package types that may not be applicable. For example, if the shipment has a length of 32cm, the sm_box package type can be skipped because it had a maximum length of 30cm. In that case, we would just use the lg_box package type.

If the shipment was under 10kg, the first weight band would apply. The key property would be used to generate the rate key to fetch:

Copy
Copied
const rateData = await context.getRates([
  "GB-GND_1-uk-lg_box-10kg"
])

If the shipment was over 10kg, the second weight band would apply. A rate would not need to be fetched because the weight band is incremental. Instead, the rate can be calculated immediately. The shipment weight should be rounded up to the next increment value and then multiplied by the amountPerIncrement. For example, if the shipment is 16.7kg, we would use an incremental weight of 18kg because the increment amount is 2. So 18 multiplied by 0.95 is 17.1. This is the incremental part of the rate. It should be added to the baseCost of the weight band, which may be zero, to get the final shipping rate. In this example, 17.1 should be added to the baseCost of 25.45 for a total of 42.55.

What should happen if the shipment was under 30cm? The shipment would now fit within a small box so both package types would apply. It is possible for the carrier logic to pick one based on some rules and return that, but it would be preferable to return rates for both package types so that the shipper can choose which rate they want to use.

Fetch rates and surcharges

Once all the required rate keys have been figured out, it's time to actually get the rate data. Surcharges can also be fetched at this point.

Copy
Copied
const [ surchargeData, rateData ] = await Promise.all([
  context.getVariables([
    "GB-surcharges",
    "GB-surcharges-GND_1",
    "GB-surcharges-GND_1-uk",
  ]),
  context.getRates(["GB-GND_1-uk-lg_box-10kg"])
]);

Return the list of applicable rates and surcharges

The last step in the process is to use the rate data to build the list of rates available to the shipper. This example will assume the data request from the previous step returned the following data.

Copy
Copied
const surchargeData = {
  "GB-surcharges-GND_1": [
    { code: "fuel", "percent_amount": "0.32" },
    { code: "convenience", "fix_amount": "3.95" }
  ]
};

const rateData = {
  "GB-GND_1-uk-lg_box-10kg": {
    currency: "GBP",
    amount: 19.95
  }
};

Given these results, we would expect a single rate returned with multiple billing line items. Your rating logic can control how surcharges may or may not apply, but the simplest approach would be to assume the surcharge applies if data is returned. In that case, a convenience line item would be added for 3.95, and a fuel line item would be added for 6.38, which is 32% of the 19.95 shipping cost. The result would look like this:

Copy
Copied
return {
  rates: [
    {
      service_code: "GND_1",
      billing_line_items: [{
        billing_category: BillingCategories.Shipping,
        carrier_billing_code: "shipping", // Arbitrary value that makes sense to the carrier
        amount: { currency: "GBP", amount: "19.95" }
      },
      {
        billing_category: BillingCategories.Uncategorized,
        carrier_billing_code: "fuel", // Value can be pulled from the `code` property of the surcharge data
        amount: { currency: "GBP", amount: "6.38" }
      },
      {
        billing_category: BillingCategories.Uncategorized,
        carrier_billing_code: "convenience", // Value can be pulled from the `code` property of the surcharge data
        amount: { currency: "GBP", amount: "3.95" }
      }],
      package_type: "large-box", // This should be the value of `CarrierPackageTypeCode` and not `Id`
    }
  ]
}

For surcharges, the rating logic can try and figure out accurate values for billing_category which will provide a better experience to the customer. In this case, the fuel surcharge could map to FuelCharge. If it's not practical to do this, Uncategorized can be used and ShipEngine will roll them up into a single Other charge.

This assembly step is where some final decisions about which rates to return can be made. If there was a rate card for both the GB and the LS12JS-GB origin prefixes, a decision should be made here about what to return. Rates from only a single rate card could be returned, the cheapest rate for each service/package combination could be returned, or some other logic could be applied. If a service or package type was specified in the shipment to be rated, the rates returned could be filtered using those values. For example, if GND_1 was specified as the service for the shipment and there was a rate for both GND_1 and OV_1, the logic could decide to return only GND_1.

Publishing

When you perform a connect publish to deploy your changes, the rating logic won't be applied to customer rate cards until the next time they update their data. If you need to force an update to all rate cards, contact the ShipEngine Connect Team for help.