As I spoke about in an earlier post, Commerce Server 2007 leverages a COM-based pipeline system to perform a series of operations on an order. The logic in the pipeline is controled, for the most part, by individual pipeline components. Each component pefroms a specific operation on the order form and then returns a status to control flow through the rest of the pipeline.
If you have used previous releases of Commerce Server you will soon realize that the out-of-the-box sample tax component is missing. I suspect that the team decided not to include it to entice people to write their own tax component which deals with regional tax complexities. In this post we will build a sample tax component which will calculate and store the tax total as part of a pipeline component library starter kit.
Before we dig into the code let's look at the core business logic in psuedo code:
OrderForm.TaxTotal = 0
For Each LineItem in OrderForm.LineItems
TaxRate = RetrieveTaxRate(OrderAddresses.RegionCode, OrderAddresses.CountryCode)
LineItem.TaxTotal += LineItem.ExtendedPrice * TaxRate
OrderForm.TaxTotal += LineItem.TaxTotal
Next
Seems pretty simple? That's because it is! Now the implementation isn't as straightforward because we're left with an untyped list and dictionary object collection which contains all of the data for our order. To make manipulation easier our first step is to build a few wrapper classes for those lists and the objects which they contain. In the attached starter kit you will find a few key classes:
- SimpleList - A wrapper for the ISimpleList-based lists which exposes a series of methods that are familiar to users of the List<T> generic list class.
- SimpleListContainer - A wrapper for the ISimpleList-based lists which can be used to expose other strongly-typed wrappers of the objects inside the list.
- Dictionary - A wrapper for the IDictionary-based collections which exposes a series of methods that are familiar to users of the Dictionary classes.
- DictionaryContainer - A wrapper for the IDictionary-based collections which can be used to expose other strongly-typed wrappers of the objects inside the list.
Using these containers we are able to wrap all IDictionary and ISimpleList containers quite easily (and even expose nullable types!):
public SimpleList<string> BasketErrors
{
get
{
if (this.basketErrors == null)
{
if (this[OrderForm.KeyNames.BasketErrors] == null)
{
this[OrderForm.KeyNames.BasketErrors] = new SimpleListClass();
}
this.basketErrors = new SimpleList<string>((ISimpleList)this[OrderForm.KeyNames.BasketErrors], StringComparer.InvariantCultureIgnoreCase);
}
return this.basketErrors;
}
}
public DictionaryContainer<OrderAddress, IDictionary> Addresses
{
get
{
if (this.addresses == null)
{
if (this[OrderForm.KeyNames.Addresses] == null)
{
this[OrderForm.KeyNames.Addresses] = new DictionaryClass();
}
this.addresses = new DictionaryContainer<OrderAddress, IDictionary>((IDictionary)this[OrderForm.KeyNames.Addresses]);
}
return this.addresses;
}
}
public SimpleListContainer<LineItem, IDictionary> LineItems
{
get
{
if (this.lineItems == null)
{
if (this[OrderForm.KeyNames.LineItems] == null)
{
this[OrderForm.KeyNames.LineItems] = new SimpleListClass();
}
this.lineItems = new SimpleListContainer<LineItem, IDictionary>((ISimpleList)this[OrderForm.KeyNames.LineItems]);
}
return this.lineItems;
}
}
public Guid OrderGroupId
{
get { return GetGuidFromStringValue(OrderForm.KeyNames.OrderGroupId).Value; }
set { this[OrderForm.KeyNames.OrderGroupId] = value.ToString(); }
}
public int CurrencyDecimalPlaces
{
get { return GetValue<int>(OrderForm.KeyNames.CurrencyDecimalPlaces); }
set { this[OrderForm.KeyNames.CurrencyDecimalPlaces] = value; }
}
public string TrackingNumber
{
get { return this[OrderForm.KeyNames.TrackingNumber] as string; }
set { this[OrderForm.KeyNames.TrackingNumber] = value; }
}
public decimal? SubTotal
{
get { return GetValue<decimal?>(OrderForm.KeyNames.SubTotal); }
set { this[OrderForm.KeyNames.SubTotal] = (value.HasValue ? new CurrencyWrapper(value.Value) : null); }
}
There is an opportunity here to use the OrderPipelineMapping.xml and a code generator to build these automatically, but that is out of scope for this post. I have included the series of objects as defined by the Starter Site to give you a full array of property type examples.
With a foundation in place it's time to write our Execute method:
ComponentErrorLevel componentErrorLevel = ComponentErrorLevel.Success;
Dictionary<string, object> logContext = new Dictionary<string, object>();
OrderForm orderForm = new OrderForm((IDictionary)pdispOrder);
PipelineContext pipelineContext = new PipelineContext((IDictionary)pdispContext);
try
{
logContext["OrderGroupId"] = orderForm.OrderGroupId;
logContext["SoldToId"] = orderForm.SoldToId;
orderForm.TaxIncluded = decimal.Zero;
orderForm.TaxTotal = decimal.Zero;
foreach (LineItem lineItem in orderForm.LineItems)
{
logContext["LineItemId"] = lineItem.LineItemId;
OrderAddress shippingAddress = orderForm.Addresses[lineItem.ShippingAddressId];
decimal taxRate = RetrieveTaxRate(pipelineContext.CommerceResources.TransactionConfigSqlConnectionString,
shippingAddress.CountryCode,
shippingAddress.RegionCode);
logContext["CountryCode"] = shippingAddress.CountryCode;
logContext["RegionCode"] = shippingAddress.RegionCode;
logContext["TaxRate"] = taxRate;
logContext["ExtendedPrice"] = lineItem.ExtendedPrice;
lineItem.TaxIncluded = decimal.Zero;
lineItem.TaxTotal = Helper.Round(orderForm.CurrencyDecimalPlaces,
lineItem.ExtendedPrice.GetValueOrDefault() * taxRate);
logContext["TaxTotal"] = lineItem.TaxTotal;
orderForm.TaxTotal += lineItem.TaxTotal;
Helper.Log(EventIdentifiers.CalculateTaxApplyingLineItemTax,
TraceEventType.Verbose,
logContext,
CalculateTax.ComponentCategory,
StringResources.ApplyingLineItemTax);
logContext.Remove("TaxTotal");
logContext.Remove("ExtendedPrice");
logContext.Remove("TaxRate");
logContext.Remove("RegionCode");
logContext.Remove("CountryCode");
logContext.Remove("LineItemId");
}
foreach (Shipment shipment in orderForm.Shipments)
{
logContext["ShipmentId"] = shipment.ShipmentId;
OrderAddress shippingAddress = orderForm.Addresses[shipment.ShippingAddressId];
decimal taxRate = RetrieveTaxRate(pipelineContext.CommerceResources.TransactionConfigSqlConnectionString,
shippingAddress.CountryCode,
shippingAddress.RegionCode);
logContext["CountryCode"] = shippingAddress.CountryCode;
logContext["RegionCode"] = shippingAddress.RegionCode;
logContext["TaxRate"] = taxRate;
logContext["ShippingTotal"] = shipment.ShippingTotal;
shipment.TaxIncluded = decimal.Zero;
shipment.TaxTotal = Helper.Round(orderForm.CurrencyDecimalPlaces,
shipment.ShippingTotal * taxRate);
logContext["TaxTotal"] = shipment.TaxTotal;
orderForm.TaxTotal += shipment.TaxTotal;
Helper.Log(EventIdentifiers.CalculateTaxApplyingShipmentTax,
TraceEventType.Verbose,
logContext,
CalculateTax.ComponentCategory,
StringResources.ApplyingShipmentTax);
logContext.Remove("TaxTotal");
logContext.Remove("ShippingTotal");
logContext.Remove("TaxRate");
logContext.Remove("RegionCode");
logContext.Remove("CountryCode");
logContext.Remove("ShipmentId");
}
}
catch (Exception exception)
{
Helper.Log(EventIdentifiers.CalculateTaxUnhandledException,
TraceEventType.Error,
logContext,
CalculateTax.ComponentCategory,
exception);
throw;
}
return (int)componentErrorLevel;
There are a few practices to note here:
- Resetting values - We always start by setting the existing total to zero. Even though the property isn't a persistent property at the basket level (designated by the "_" prefix), this will help us ensure we aren't adding any unnecessary charges.
- Catch and log general exceptions - While you typically don't want to catch a general exception, because the pipeline system structure data goes from .NET to COM to .NET you lose certain properties in exceptions that do occur (e.g. call stack). Instead of losing that data we'll log it and rethrow it. For those who are wondering why we don't return a Failure error level David Messner blogged that it's a better practice to throw exceptions when in .NET.
- Data access methods are encapsulated in their own methods - Tax rate retrieval is contained inside a separate method. While we pull the value from the TransactionConfig resource, you can design it to pull from other sources such as third-party tax services if you wish. The separate method also keeps the messy details of data retrieval out of our business logic.
- Readable variable names - An important part to any development is leaving your code clean and readable for other developers to maintain. With everything being boiled down to IL and small machine readable identifiers there is no loss in spending a few extra characters to make your code readable.
Before we wrap up we'll take this opportunity to go a bit further with our example. You may have a need to exclude certain products from taxation. To facilitate this we'll add a check for a property indicating that the product is excluded from taxation. To make it flexibile as to where that property value comes from (e.g. product catalog, user profile) we'll make it configurable as well. With this change in place our core Execute logic looks as follows:
- Retrieve exemption information at multiple levels.
- Send the exemption status with the database call so it can exclude any unnecessary data in its rate retrieval process.
Our main execute function now looks like this:
logContext["OrderGroupId"] = orderForm.OrderGroupId;
logContext["SoldToId"] = orderForm.SoldToId;
orderForm.TaxIncluded = decimal.Zero;
orderForm.TaxTotal = decimal.Zero;
bool countryTaxExempt = false;
bool regionTaxExempt = false;
if ((EnableTaxExemptionRules) &&
(CountryTaxExemptionKeySource == KeySources.OrderForm))
{
countryTaxExempt = orderForm.GetValue<bool>(CountryTaxExemptionKey);
}
if ((EnableTaxExemptionRules) &&
(RegionTaxExemptionKeySource == KeySources.OrderForm))
{
regionTaxExempt = orderForm.GetValue<bool>(RegionTaxExemptionKey);
}
foreach (LineItem lineItem in orderForm.LineItems)
{
logContext["LineItemId"] = lineItem.LineItemId;
OrderAddress shippingAddress = orderForm.Addresses[lineItem.ShippingAddressId];
if ((EnableTaxExemptionRules) &&
(CountryTaxExemptionKeySource == KeySources.LineItem))
{
countryTaxExempt = lineItem.GetValue<bool>(CountryTaxExemptionKey);
}
if ((EnableTaxExemptionRules) &&
(RegionTaxExemptionKeySource == KeySources.LineItem))
{
regionTaxExempt = lineItem.GetValue<bool>(RegionTaxExemptionKey);
}
decimal taxRate = RetrieveTaxRate(pipelineContext.CommerceResources.TransactionConfigSqlConnectionString,
shippingAddress.CountryCode,
countryTaxExempt,
shippingAddress.RegionCode,
regionTaxExempt);
logContext["CountryCode"] = shippingAddress.CountryCode;
logContext["CountryTaxExempt"] = countryTaxExempt;
logContext["RegionCode"] = shippingAddress.RegionCode;
logContext["RegionTaxExempt"] = regionTaxExempt;
logContext["TaxRate"] = taxRate;
logContext["ExtendedPrice"] = lineItem.ExtendedPrice;
lineItem.TaxIncluded = decimal.Zero;
lineItem.TaxTotal = Helper.Round(orderForm.CurrencyDecimalPlaces,
lineItem.ExtendedPrice.GetValueOrDefault() * taxRate);
logContext["TaxTotal"] = lineItem.TaxTotal;
orderForm.TaxTotal += lineItem.TaxTotal;
Helper.Log(EventIdentifiers.CalculateTaxApplyingLineItemTax,
TraceEventType.Verbose,
logContext,
CalculateTax.ComponentCategory,
StringResources.ApplyingLineItemTax);
logContext.Remove("TaxTotal");
logContext.Remove("ExtendedPrice");
logContext.Remove("TaxRate");
logContext.Remove("RegionTaxExempt");
logContext.Remove("RegionCode");
logContext.Remove("CountryTaxExempt");
logContext.Remove("CountryCode");
logContext.Remove("LineItemId");
}
if ((EnableTaxExemptionRules) &&
(CountryTaxExemptionKeySource == KeySources.LineItem))
{
countryTaxExempt = false;
}
if ((EnableTaxExemptionRules) &&
(RegionTaxExemptionKeySource == KeySources.LineItem))
{
regionTaxExempt = false;
}
foreach (Shipment shipment in orderForm.Shipments)
{
logContext["ShipmentId"] = shipment.ShipmentId;
OrderAddress shippingAddress = orderForm.Addresses[shipment.ShippingAddressId];
decimal taxRate = RetrieveTaxRate(pipelineContext.CommerceResources.TransactionConfigSqlConnectionString,
shippingAddress.CountryCode,
countryTaxExempt,
shippingAddress.RegionCode,
regionTaxExempt);
logContext["CountryCode"] = shippingAddress.CountryCode;
logContext["CountryTaxExempt"] = countryTaxExempt;
logContext["RegionCode"] = shippingAddress.RegionCode;
logContext["RegionTaxExempt"] = regionTaxExempt;
logContext["TaxRate"] = taxRate;
logContext["ShippingTotal"] = shipment.ShippingTotal;
shipment.TaxIncluded = decimal.Zero;
shipment.TaxTotal = Helper.Round(orderForm.CurrencyDecimalPlaces,
shipment.ShippingTotal * taxRate);
logContext["TaxTotal"] = shipment.TaxTotal;
orderForm.TaxTotal += shipment.TaxTotal;
Helper.Log(EventIdentifiers.CalculateTaxApplyingShipmentTax,
TraceEventType.Verbose,
logContext,
CalculateTax.ComponentCategory,
StringResources.ApplyingShipmentTax);
logContext.Remove("TaxTotal");
logContext.Remove("ShippingTotal");
logContext.Remove("TaxRate");
logContext.Remove("RegionTaxExempt");
logContext.Remove("RegionCode");
logContext.Remove("CountryTaxExempt");
logContext.Remove("CountryCode");
logContext.Remove("ShipmentId");
}
To expose the configuration we'll use the IPipelineComponentAdmin, IPipelineComponentUI, ISpecifyPipelineComponentUI, and IPersistDictionary interfaces to build a dialog box for configuration inside the pipeline editor:
The key methods for loading and saving data is the Load and Save methods:
public void Load(object pdispDict)
{
CalculateTaxConfiguration configuration = new CalculateTaxConfiguration((IDictionary)pdispDict);
this.enableTaxExemptionRules = configuration.EnableTaxExemptionRules;
this.countryTaxExemptionKey = configuration.CountryTaxExemptionKey;
this.countryTaxExemptionKeySource = configuration.CountryTaxExemptionKeySource;
this.regionTaxExemptionKey = configuration.RegionTaxExemptionKey;
this.regionTaxExemptionKeySource = configuration.RegionTaxExemptionKeySource;
this.isDirty = false;
}
public void Save(object pdispDict, int fSameAsLoad)
{
CalculateTaxConfiguration configuration = new CalculateTaxConfiguration((IDictionary)pdispDict);
configuration.EnableTaxExemptionRules = this.enableTaxExemptionRules;
configuration.CountryTaxExemptionKey = this.countryTaxExemptionKey;
configuration.CountryTaxExemptionKeySource = this.countryTaxExemptionKeySource;
configuration.RegionTaxExemptionKey = this.regionTaxExemptionKey;
configuration.RegionTaxExemptionKeySource = this.regionTaxExemptionKeySource;
this.isDirty = false;
}
The pipeline editor calls these methods with a IDictionary that will be streamed and saved to the pipeline configuration file (PCF). When the editor invokes your dialog you'll be passed the instance of the component from which you can retrieve configuration values:
public void ShowProperties(object pdispComponent)
{
CalculateTax calculateTax = (CalculateTax)pdispComponent;
this.EnableTaxExemptionRules.Checked = calculateTax.EnableTaxExemptionRules;
this.CountryTaxExemptionKey.Text = calculateTax.CountryTaxExemptionKey;
this.CountryTaxExemptionKeySource.Text = calculateTax.CountryTaxExemptionKeySource.ToString();
this.RegionTaxExemptionKey.Text = calculateTax.RegionTaxExemptionKey;
this.RegionTaxExemptionKeySource.Text = calculateTax.RegionTaxExemptionKeySource.ToString();
EnableTaxExemptionRules_CheckedChanged(null, EventArgs.Empty);
DialogResult result = this.ShowDialog();
if (result == DialogResult.OK)
{
calculateTax.EnableTaxExemptionRules = this.EnableTaxExemptionRules.Checked;
calculateTax.CountryTaxExemptionKey = this.CountryTaxExemptionKey.Text;
calculateTax.CountryTaxExemptionKeySource = (CalculateTax.KeySources)Enum.Parse(typeof(CalculateTax.KeySources), this.CountryTaxExemptionKeySource.Text, true);
calculateTax.RegionTaxExemptionKey = this.RegionTaxExemptionKey.Text;
calculateTax.RegionTaxExemptionKeySource = (CalculateTax.KeySources)Enum.Parse(typeof(CalculateTax.KeySources), this.RegionTaxExemptionKeySource.Text, true);
}
}
One thing to note is that if an exception is thrown that you may not see it in the pipeline editor. If your dialog is behaving in a suspectable manner attach to the process using Visual Studio and watch the Output window for exceptions being thrown.
When the pipeline is executed the pipeline system will initialize your component and pass in the dictionary with the configuration values stored in the PCF. It does remind me that it would be great to see documentation at some point of the event life cycle for a pipeline component.
Now that the component is complete I added one last bit of functionality to make deployment easier - a pipeline component category attribute. This attribute does away with the need for the pipeline component registration wizard by allowing you to decorate your class with the category/stage identifiers that your component applies to. Then you can use InstallUtil to install/uninstall the registry entries assocaited with it:
[ComVisible(true), Description("RSG.CalculateTax"), ProgId("RSG.CalculateTax")]
[Guid("01f7bda9-9cb0-458d-95f4-7ac4ca33c4fc")]
[PipelineCategory(PipelineCategories.CommerceServer, PipelineCategories.AllStages,
PipelineCategories.Tax)]
public class CalculateTax : IPersistDictionary, IPipelineComponent,
IPipelineComponentAdmin, IPipelineComponentDescription,
ISpecifyPipelineComponentUI
{
Finally there are 378 unit tests that cover just over half of the code in the library. Because I am working with COM objects that aren't fully documented in addition to the potential for type issues I wanted to cover some critical bits of code to ensure they worked as I expected. I'm proud of the code that executes the pipeline components and the entire pipeline itself because it makes it easy to ensure your logic will work as expected. Thanks to Nihit Kaul for a starting point on the pipeline side. I have made it a bit more dynamic than his project so you can rely on the app.config sections which you would normally see in your web project to ensure the pipeline functions in a similar manner. I also managed to expose some short comings of testing with VSTS (no support for satellite assemblies) and Commerce Server under Vista (pipeline tests don't run as a normal user, you need to elevate to admin, but that's okay because it's technically not supported until SP1 is released).
Thanks to Andy Miller for pointing this out - To get this all working you will need a copy of the Enterprise Library 2.0 (I have tested with the 2554 patch). That being said, if log4net and some other data access layer is your cup of tea the functions are pretty isolated inside the Helper.cs and CalculateTax.cs components. You should be able to replace the code with little problem.
After weeks of work when ever I found time please find attached the 17,000+ line starter kit for building a pipeline component library. I hope you find it useful and I appreciate any and all feedback you have.