Navigation


Upcoming courses:

  • Aarhus, Denmark, March 5 - 9, 2012
  • New York City, USA, March 26 - 30, 2012
Read more on our website

About me

Brian Holmgård Kristensen

Hi, I'm Brian. I'm a Danish guy primarily working with ASP.NET e-commerce solutions using Microsoft Commerce Server.

I'm co-founder and core-member of Aarhus .NET Usergroup (ANUG), which is a offline community for .NET developers in Denmark.

You can visit my View Brian Holmgård Kristensen's profile on LinkedIn or follow me on Twitter @brianh_dk. Also please feel free to contact me via e-mail Send me an e-mail.

Categories

On this page

How to implement Custom Shipping Methods in Commerce Server

Archive

Blogroll

Disclaimer
The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.

RSS 2.0

Send mail to the author(s) E-mail

Total Posts: 36
This Year: 0
This Month: 0
This Week: 0
Comments: 10

Sign In

Follow me on Twitter @brianh_dk
 Friday, 15 October 2010

A couple of years back I wrote a blog-post on How to extend Commerce Server Payment Methods and Shipment Methods which described how you could use the Profiles System in Commerce Server to extend properties of Shipment- and Payment Methods and with very little effort customize the UI of the Customer And Orders Manager to allow easy management of these new properties. This technique is definitely still valid, but I’ve would like to explain another approach that overrides the overall of how Commerce Server handles Shipping Methods to allow custom business logic, custom pricing etc.

Also this post serves as a (late, but promised) follow up answer to a question asked on the Commerce Server Forum on MSDN (http://social.msdn.microsoft.com/Forums/en-US/commserver2009/thread/bca894c3-122a-4f11-8fe0-35e2b49de1b8/#5cad8631-ca9f-47bf-8bd9-5ad01d77ba8c) on how the best way to implement custom business logic for handling shipping in Commerce Server.

In this blog post I’ll show you can implement custom shipping methods in a Commerce Server 2007/2009 project. In order to better explain the “Why”-part, I’ll start by briefly explaining how Commerce Server works out of the box in regards to applying shipping on a basket. Last but not least I’ll explain the “How”-part, so you can implement this in your own project.

Shipping out of the box

Shipping Methods are configured in Customer And Orders Manager. Here you specify name and description for the different languages that you support, the cost of a given shipping method based on weight, subtotal or quantity and a Shipping Cost Calculator, which is a pipeline component that automatically gets executed from the Total-pipeline. As for prices you cannot specify multiple prices in multiple currencies – so you are stuck with only prices in one currency. Also important to mention is that there is no supported way to extend this schema, e.g. adding new properties, as you would also read about in my previously mentioned post.

This screenshot below shows the link between a Shipping Method created in Customer and Orders Manager and how and where this is persisted in the database.

ShippingMethods

In short Shipping Methods are added to a Basket by specifying a ShippingMethodId (Guid) to your Line Items as shown below for both 2007 and 2009 version of Commerce Server.

In Commerce Server 2007:

   1:  var lineItem = new LineItem("TestCatalog", "ProductID", null /* VariantID */, 1 /* Quantity */)
   2:  {
   3:      ShippingMethodId = new Guid("MethodId on Shipping Method")
   4:  };

In Commerce Server 2009:

   1:  lineItem.SetPropertyValue("CatalogName", "TestCatalog");
   2:  lineItem.SetPropertyValue("ProductId", "ProductID");
   3:  lineItem.SetPropertyValue("Quantity", 1 /* Quantity */);
   4:  lineItem.SetPropertyValue("ShippingMethodId", "GroupId on Shipping Method");

 

After adding a Line Item to the Basket you run the Total-pipeline in which there is a number of pipeline component responsible of requesting the database and actually create one or more instances of the Microsoft.CommerceServer.Runtime.Orders.Shipment class based on the ShippingMethodId on the Line Items. This new instance then gets added to the Shipments-collection on the OrderForm. Basically the same as I’m doing manually in the code example below:

   1:  var orderForm = basket.OrderForms[0];
   2:  var shipment = new Shipment();
   3:  orderForm.Shipments.Add(shipment);

 

The bottom-line is that you are not in control of the actual creation process of the Shipment instance, as this is taken care of in the Total-pipeline by the two components marked in red in the screenshot below.

PipelineComponents

If you do try to add it manually the Total-pipeline will throw an error because you did not specify a ShippingMethodId on your Line Items.

Why use custom shipping?

On most of the e-commerce projects I’ve been working on, pricing is a very complex thing – it heavily depends on a lot of context information like customer, country, currency, content of the basket, suppliers and sometimes more making it impossible to setup in Customer and Orders Manager. Sure you can try to create your own Shipping Cost Calculator pipeline component, but that requires you to do work and business logic in the Pipeline infrastructure of Commerce Server, which is cumbersome, because  you are dealing with COM, magic strings, and weak-types like the IDictionary and ISimpleList. Also it is difficult to interact with your existing infrastructure like services, repositories etc from your pipelines, and it is hard to automate tests for this. Pipeline components are just not the right place to put your business logic in my opinion.

So what we would really like is to be able to create the Shipping Methods from within our domain ourselves, being able to specify the price in that proces, e.g. having the price coming from a calculation made in the backend/ERP-system. And I’ll show you how to accomplish that in the next section.

How to implement custom shipping

First off we need to modify the Total-pipeline. The two pipeline components marked in red in the previous image needs to be removed thus making the Total-pipeline look like in the screenshot below:

CustomizedPipeline

The next steps really depend on whether you are working on a CS 2007 or CS 2009 project. I’ll start by explaining the CS 2007 approach:

CS 2007

   1:  public void CreateBasketInCs2007()
   2:  {
   3:      // Create a new basket
   4:      var basket =
   5:          CommerceContext.Current.OrderSystem.GetBasket(Guid.NewGuid(), "Default2007");
   6:   
   7:      // Create a new OrderForm
   8:      if (basket.OrderForms.Count == 0)
   9:          basket.OrderForms.Add(new OrderForm());
  10:   
  11:      // Add some line items
  12:      foreach (string productId in new[] { "2-1", "2-2" })
  13:      {
  14:          basket.OrderForms[0].LineItems.Add(new LineItem("TestCatalog", productId, null, 1));
  15:      }
  16:   
  17:      // Add an address
  18:      var address = new OrderAddress("Default", String.Empty) { FirstName = "First Name" };
  19:      basket.Addresses.Add(address);
  20:   
  21:      // Add your custom shipment with custom price (300)
  22:      basket.OrderForms[0].Shipments.Add(
  23:          new Shipment 
  24:      { 
  25:          ShippingMethodName = "MyShippingMethod",
  26:          ShipmentTrackingNumber = "My Tracking",
  27:          ShipmentTotal = 300m, 
  28:          ShippingAddressId = address.OrderAddressId 
  29:      });
  30:   
  31:      // Associate Shipping and Line Items
  32:      basket.OrderForms[0].Shipments[0].LineItemIndexes.Add(0);
  33:      basket.OrderForms[0].Shipments[0].LineItemIndexes.Add(1);
  34:   
  35:      // Execute the pipelines
  36:      using (var pipeline = new PipelineInfo("Basket", OrderPipelineType.Basket))
  37:      {
  38:          _writer.WriteLine(basket.RunPipeline(pipeline));
  39:      }
  40:   
  41:      using (var pipeline = new PipelineInfo("Total", OrderPipelineType.Total))
  42:      {
  43:          _writer.WriteLine(basket.RunPipeline(pipeline));
  44:      }
  45:   
  46:      basket.Save();
  47:  }

The example above creates a new Basket, adds some line items, and adds a Shipment instance with a custom price. The point is that this custom price could come from anywhere: web-service call to ERP, custom database, custom pricing calculation logic, etc. Here is the result of the basket shown in Customer and Orders Manager:

BasketCS2007-1 BasketCS2007-2

To illustrate the same in CS 2009 we need to do a bit more work as shown below.

CS 2009

Lets start by looking at the actual query code:

   1:  public void CreateBasketInCs2009()
   2:  {
   3:      var basketUpdateQuery =
   4:          new CommerceUpdate<CommerceEntity, CommerceModelSearch<CommerceEntity>, CommerceBasketUpdateOptionsBuilder>("Basket");
   5:   
   6:      basketUpdateQuery.SearchCriteria.Model.SetPropertyValue("UserId", Guid.NewGuid().ToString());
   7:      basketUpdateQuery.SearchCriteria.Model.SetPropertyValue("BasketType", 0); // Basket
   8:      basketUpdateQuery.SearchCriteria.Model.SetPropertyValue("Name", "Default2009");
   9:   
  10:      // Must be set to "ReadyForCheckout" in order to have Total pipeline executed
  11:      basketUpdateQuery.Model.SetPropertyValue("Status", "ReadyForCheckout");
  12:   
  13:      basketUpdateQuery.Model.SetPropertyValue("BasketType", 0); // 0 = Basket
  14:   
  15:      // Decides whether to run pipelines or not
  16:      basketUpdateQuery.UpdateOptions.RefreshBasket = true;
  17:   
  18:      // Add an address
  19:      var address = new CommerceEntity("Address")
  20:      {
  21:          Id = Guid.NewGuid().ToString("B")
  22:      };
  23:   
  24:      address.SetPropertyValue("AddressName", "Home");
  25:      address.SetPropertyValue("FirstName", "First Name");
  26:      address.SetPropertyValue("ProfileAddressId", String.Empty);
  27:   
  28:      basketUpdateQuery.RelatedOperations.Add(
  29:          new CommerceCreateRelatedItem<CommerceEntity>("Addresses", "Address") { Model = address });
  30:   
  31:      // Add some line items
  32:      foreach (string productId in new[] { "2-1", "2-2" })
  33:      {
  34:          var lineItem = new CommerceEntity("LineItem");
  35:   
  36:          lineItem.SetPropertyValue("ProductId", productId);
  37:          lineItem.SetPropertyValue("Quantity", 1);
  38:          lineItem.SetPropertyValue("CatalogName", "TestCatalog");
  39:          lineItem.SetPropertyValue("ShippingAddressId", address.Id);
  40:   
  41:          // if using multiple shippings - use this to pair line item and shipping
  42:          lineItem.SetPropertyValue("ShippingMethodName", "MyShippingMethod");
  43:   
  44:          basketUpdateQuery.RelatedOperations.Add(
  45:              new CommerceCreateRelatedItem<CommerceEntity>("LineItems", "LineItem") { Model = lineItem });
  46:      }
  47:   
  48:      // Add a custom shipment
  49:      var shipment = new CommerceEntity("Shipment");
  50:      shipment.SetPropertyValue("Total", 300m); // custom price right here!
  51:      shipment.SetPropertyValue("ShippingAddressId", address.Id);
  52:      shipment.SetPropertyValue("TrackingNumber", "some-tracking-code");
  53:      shipment.SetPropertyValue("StatusCode", "some-status");
  54:      shipment.SetPropertyValue("ShippingMethodName", "MyShippingMethod");
  55:   
  56:      basketUpdateQuery.RelatedOperations.Add(
  57:          new CommerceCreateRelatedItem<CommerceEntity>("Shipments") { Model = shipment });
  58:   
  59:      var operationServiceAgent = new OperationServiceAgent();
  60:   
  61:      var response =
  62:          operationServiceAgent
  63:              .ProcessRequest(Request.CreateCommerceRequestContext(), basketUpdateQuery.ToRequest())
  64:              .OperationResponses
  65:              .Single() as CommerceUpdateOperationResponse;
  66:   
  67:      foreach (var entity in response.CommerceEntities ?? Enumerable.Empty<CommerceEntity>())
  68:          entity.Output(_writer);
  69:   
  70:      _writer.WriteLine("Count = {0}", response.Count);
  71:  }

Executing this query with the standard Operation Sequence Components and Translators in the Multi Channel Foundation API will fail. The reason why is that there is logic in these classes that validates whether Line Items have a valid ShippingMethodId in order to execute the Total-pipeline, and also there is simply no translator to translate from a CS 2009 Shipment-CommerceEntity to a Shipment-type in CS 2007.

More work needs to be put in this effort, lets start with the Translator part. First off we need to extend the current LineItemTranslator to have the ShippingMethodName translated:

   1:  public class CustomShippingSupportingLineItemTranslator : LineItemTranslator
   2:  {
   3:      protected override bool TranslateToStronglyTypedCommerceServerProperty(LineItem commerceServerObject, string commerceServerPropertyName, object value)
   4:      {
   5:          bool result =
   6:              base.TranslateToStronglyTypedCommerceServerProperty(commerceServerObject, commerceServerPropertyName, value);
   7:   
   8:          if (!result && 
   9:              value is String &&
  10:              String.Equals(commerceServerPropertyName, "ShippingMethodName", StringComparison.OrdinalIgnoreCase))
  11:          {
  12:              commerceServerObject.ShippingMethodName = value.ToString();
  13:              result = true;
  14:          }
  15:   
  16:          return result;
  17:      }
  18:  }

And register this in the ChannelConfiguration.config:

   1:  <Translator
   2:    sourceModelName="LineItem"
   3:    destinationType="Microsoft.CommerceServer.Runtime.Orders.LineItem, Microsoft.CommerceServer.Runtime, Version=6.0.1.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
   4:    type="Extensions.Providers.Translators.CustomShippingSupportingLineItemTranslator, Extensions, Version=1.0.0.0, Culture=neutral, PublicKeyToken=4c1d6c3df403d290"/>

 

Next up is extending the existing ShipmentTranslator to support External Entity translation, like this:

   1:  public class ExternalEntitySupportingShipmentTranslator : ShipmentTranslator, IToExternalEntityTranslator
   2:  {
   3:      private readonly PropertyTranslator<Shipment> _propertyTranslator;
   4:   
   5:      public ExternalEntitySupportingShipmentTranslator()
   6:      {
   7:          _propertyTranslator = 
   8:              new PropertyTranslator<Shipment>(
   9:                  null, 
  10:                  null,
  11:                  TranslateToStronglyTypedCommerceServerProperty,
  12:                  TranslateToWeaklyTypedCommerceServerProperty);
  13:      }
  14:   
  15:      public void Translate(CommerceEntity sourceCommerceEntity, object destination)
  16:      {
  17:          if (sourceCommerceEntity == null) throw new ArgumentNullException("sourceCommerceEntity");
  18:          if (destination == null) throw new ArgumentNullException("destination");
  19:   
  20:          _propertyTranslator.TranslateToCommerceServer(sourceCommerceEntity, destination as Shipment, null);
  21:      }
  22:   
  23:      protected virtual bool TranslateToWeaklyTypedCommerceServerProperty(Shipment commerceServerObject, string commerceServerPropertyName, object value)
  24:      {
  25:          commerceServerObject[commerceServerPropertyName] = value;
  26:          return true;
  27:      }
  28:   
  29:      protected virtual bool TranslateToStronglyTypedCommerceServerProperty(Shipment commerceServerObject, string commerceServerPropertyName, object value)
  30:      {
  31:          switch (commerceServerPropertyName)
  32:          {
  33:              case "ShipmentTotal":
  34:                  if (value != null)
  35:                      commerceServerObject.ShipmentTotal = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
  36:                  break;
  37:   
  38:              case "ShippingAddressId":
  39:                  commerceServerObject.ShippingAddressId = value as string;
  40:                  break;
  41:   
  42:              case "ShippingMethodName":
  43:                  commerceServerObject.ShippingMethodName = value as string;
  44:                  break;
  45:   
  46:              case "ShippingMethodId":
  47:                  Guid methodId = (value is string) ? new Guid(value as string) : Guid.Empty;
  48:                  commerceServerObject.ShippingMethodId = methodId;
  49:                  break;
  50:   
  51:              case "Status":
  52:                  commerceServerObject.Status = value as string;
  53:                  break;
  54:   
  55:              case "ShipmentTrackingNumber":
  56:                  commerceServerObject.ShipmentTrackingNumber = value as string;
  57:                  break;
  58:   
  59:              default:
  60:                  return false;
  61:          }
  62:   
  63:          return true;
  64:      }
  65:  }

And again register this in the ChannelConfiguration.config:

   1:  <Translator
   2:    sourceModelName="Shipment"
   3:    destinationType="Microsoft.CommerceServer.Runtime.Orders.Shipment, Microsoft.CommerceServer.Runtime, Version=6.0.1.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
   4:    type="Extensions.Providers.Translators.ExternalEntitySupportingShipmentTranslator, Extensions, Version=1.0.0.0, Culture=neutral, PublicKeyToken=4c1d6c3df403d290" />

 

This covers the translation part, so we can map/translate from a CS 2009 entity to a CS 2007 entity.

The next part is where it gets a little tricky. We need to create a custom Operation Sequence Component, that will take care of the actual creation of the CS 2007 Shipment-instance, and add this to the Shipments collection of the Basket that we are executing a query against.

To create a custom Operation Sequence Component, we create the following class:

   1:  public class CustomShipmentsProcessor : BasketRelatedItemOperationSequenceComponent
   2:  {
   3:      protected override string RelationshipName
   4:      {
   5:          get { return "Shipments"; }
   6:      }
   7:   
   8:      protected override void CreateRelatedItem(CommerceCreateRelatedItem createRelatedItemOperation)
   9:      {
  10:          CommerceEntity shipmentEntity = createRelatedItemOperation.Model;
  11:   
  12:          var newShipment =
  13:              CommerceServerClassFactory.CreateInstance<Shipment>(
  14:                  shipmentEntity.ModelName, CommerceServerArea.Orders);
  15:   
  16:          shipmentEntity.Id = newShipment.ShipmentId.ToString("B");
  17:   
  18:          Translator.ToExternalEntity(shipmentEntity, newShipment);
  19:   
  20:          OrderForm orderForm = CachedOrderGroup.GetDefaultOrderForm();
  21:   
  22:          UpdateShippingLineItemsAssociation(newShipment, orderForm);
  23:   
  24:          orderForm.Shipments.Add(newShipment);
  25:      }
  26:   
  27:      public override void ExecuteUpdate(CommerceUpdateOperation updateOperation, Microsoft.Commerce.Broker.OperationCacheDictionary operationCache, CommerceUpdateOperationResponse response)
  28:      {
  29:          base.ExecuteUpdate(updateOperation, operationCache, response);
  30:   
  31:          EnsureLineItemsHasShippingMethod();
  32:      }
  33:   
  34:      private void EnsureLineItemsHasShippingMethod()
  35:      {
  36:          OrderForm orderForm = CachedOrderGroup.GetDefaultOrderForm();
  37:   
  38:          foreach (LineItem lineItem in
  39:              orderForm.LineItems
  40:                  .OfType<LineItem>()
  41:                  .Where(x => x.ShippingMethodId == Guid.Empty))
  42:          {
  43:              lineItem.ShippingMethodId = Guid.NewGuid();
  44:          }
  45:      }
  46:   
  47:      protected override void DeleteRelatedItem(CommerceDeleteRelatedItem deleteRelatedItemOperation)
  48:      {
  49:          string shipmentId = GetSearchModelId(deleteRelatedItemOperation, "Shipment", true);
  50:   
  51:          if (!String.IsNullOrEmpty(shipmentId))
  52:          {
  53:              Shipment deletingShipment = GetShipmentFromCachedOrderGroup(shipmentId);
  54:              CachedOrderGroup.GetDefaultOrderForm().Shipments.Remove(deletingShipment);
  55:          }
  56:          else
  57:              CachedOrderGroup.GetDefaultOrderForm().Shipments.Clear();
  58:      }
  59:   
  60:      protected override void UpdateRelatedItem(CommerceUpdateRelatedItem updateRelatedItemOperation)
  61:      {
  62:          OrderForm orderForm = CachedOrderGroup.GetDefaultOrderForm();
  63:   
  64:          CommerceEntity model = updateRelatedItemOperation.GetModel("Shipment");
  65:          string shipmentId = GetSearchModelId(updateRelatedItemOperation, "Shipment", true);
  66:   
  67:          if (!String.IsNullOrEmpty(shipmentId))
  68:          {
  69:              Shipment updatingShipment = GetShipmentFromCachedOrderGroup(shipmentId);
  70:              Translator.ToExternalEntity(model, updatingShipment);
  71:              UpdateShippingLineItemsAssociation(updatingShipment, orderForm);
  72:          }
  73:          else
  74:          {
  75:              foreach (Shipment shipment in orderForm.Shipments)
  76:                  Translator.ToExternalEntity(model, shipment);
  77:          }
  78:      }
  79:   
  80:      protected virtual Shipment GetShipmentFromCachedOrderGroup(string shipmentId)
  81:      {
  82:          OrderForm orderForm = CachedOrderGroup.GetDefaultOrderForm();
  83:   
  84:          int index = orderForm.Shipments.IndexOf(new Guid(shipmentId));
  85:   
  86:          if (index < 0)
  87:              throw ItemDoesNotExist("Shipment", shipmentId);
  88:   
  89:          return orderForm.Shipments[index];
  90:      }
  91:   
  92:      protected virtual void UpdateShippingLineItemsAssociation(Shipment shipment, OrderForm orderForm)
  93:      {
  94:          foreach (LineItem lineItem in
  95:              orderForm.LineItems
  96:                  .OfType<LineItem>()
  97:                  .Where(x => 
  98:                      String.IsNullOrEmpty(x.ShippingMethodName) ||
  99:                      String.Equals(x.ShippingMethodName, shipment.ShippingMethodName, StringComparison.OrdinalIgnoreCase)))
 100:          {
 101:              lineItem.ShippingMethodId = shipment.ShipmentId;
 102:              shipment.LineItemIndexes.Add(lineItem.Index);
 103:          }
 104:      }
 105:   
 106:      protected virtual FaultException<ItemDoesNotExistFault> ItemDoesNotExist(string entityName, string entityId)
 107:      {
 108:          string message = ProviderResources.ExceptionMessages.GetMessage("ItemDoesNotExist", new object[0]);
 109:   
 110:          var detail = new ItemDoesNotExistFault(message);
 111:          if (!String.IsNullOrEmpty(entityName))
 112:          {
 113:              detail.CommerceEntityName = entityName;
 114:          }
 115:          if (!String.IsNullOrEmpty(entityId))
 116:          {
 117:              detail.CommerceEntityId = entityId;
 118:          }
 119:   
 120:          return new FaultException<ItemDoesNotExistFault>(detail, message);
 121:      }
 122:  }

 

This Operation Sequence Component will make sure that all the prerequisites required for the Total-pipeline component to be executed by the standard OrderPipelineProcessor are met, and it will make sure that Line Items and the newly created Shipments are associated to each other. This is particular necessary if you need multiple shipment – having different line items using different shipment methods.

To register the new Operation Sequence Component we again have to do some work in the ChannelConfiguration.config:

   1:  <Component name="Requested Promo Codes processor" type="Microsoft.Commerce.Prov…">
   2:    <Configuration
   3:      customElementName="RequestedPromoCodesProcessorConfiguration"
   4:      customElementType="Microsoft.Commerce.Providers.Components.RequestedPromoCodesPr…">
   5:      <RequestedPromoCodesProcessorConfiguration promoUserIdentityProperty="Email"/>
   6:    </Configuration>
   7:  </Component>
   8:            
   9:  <Component name="Custom Shipments processor" type="Extensions.Providers.Components.CustomShipmentsProcessor, Extensions, Version=1.0.0.0, Culture=neutral, PublicKeyToken=4c1d6c3df403d290" />
  10:            
  11:  <Component name="Payments processor" type="Microsoft.Commerce.Providers.Components.PaymentsProcessor, Microsoft.Commerce.Providers, Version=1.0.0.0, Culture=neutral,PublicKeyToken=31bf3856ad364e35" />
  12:  <Component name="Targeting Profiles" type="Microsoft.Commerce.Providers.Components.TargetingProfilesProcessor, Microsoft.Commerce.Providers, Version=1.0.0.0, Culture=neutral,PublicKeyToken=31bf3856ad364e35" />
  13:  <Component name="Order Pipelines Processor" type="Microsoft.Commerce.Providers.Components.OrderPipelinesProcessor, Microsoft.Commerce.Providers, Version=1.0.0.0, Culture=neutral,PublicKeyToken=31bf3856ad364e35">

The component needs to be added before the Order Pipelines Processor.

Thats it. Execute the query, and the result will look like the following in Customer and Orders Manager:

 

BasketCS2009-1 BasketCS2009-2

 

I hope this was useful. If you have any questions or want me to elaborate more on this subject please don't hesitate to contact me in any way.

Posted on Friday, 15 October 2010 09:36:37 (Romance Standard Time, UTC+01:00)
# | Comments [0]