Remembering Thoughts
  Twitter GitHub RSS

Sales Tax Calculator using SpecFlow & The State Pattern And Chained Commands

Using SpecFlow heres how I solved the following problem

PROBLEM : SALES TAXES

Basic sales tax is applicable at a rate of 10% on all goods, except books, food, and medical products that are exempt. Import duty is an additional sales tax applicable on all imported goods at a rate of 5%, with no exemptions._

When I purchase items I receive a receipt which lists the name of all the items and their price (including tax), finishing with the total cost of the items, and the total amounts of sales taxes paid. The rounding rules for sales tax are that for a tax rate of n%, a shelf price of p contains (np/100 rounded up to the nearest 0.05) amount of sales tax.

Write an application that prints out the receipt details for these shopping baskets…

INPUT:
Input 1: 1 book at 12.49 1 music CD at 14.99 1 chocolate bar at 0.85

Input 2: 1 imported box of chocolates at 10.00 1 imported bottle of perfume at 47.50

Input 3: 1 imported bottle of perfume at 27.99 1 bottle of perfume at 18.99 1 packet of headache pills at 9.75 1 box of imported chocolates at 11.25
OUTPUT
Output 1: 1 book : 12.49 1 music CD: 16.49 1 chocolate bar: 0.85 Sales Taxes: 1.50 Total: 29.83

Output 2: 1 imported box of chocolates: 10.50 1 imported bottle of perfume: 54.65 Sales Taxes: 7.65 Total: 65.15

Output 3: 1 imported bottle of perfume: 32.19 1 bottle of perfume: 20.89 1 packet of headache pills: 9.75 1 imported box of chocolates: 11.85 Sales Taxes: 6.70 Total: 74.68 ==========

A Quick overview of the application

The solution is a console application that parses given files (i.e. Input1.txt) and creates an output formatted as requested in the problem.

It looks like this:

image

Projects

SalesTaxApplication    – Console Application that imports text files ie input1.txt – these a copied to the bin folder at compile time – but you just just pass in a file path McKelt.SalesTaxApplication [/s filename] – McKelt.SalesTaxApplication /s input3.txt Domain                       – contains core model & other shared classes Acceptance Tests        – uses SpecFlow for BDD acceptance tests - was hoping to have reusable steps but didnt get round to this sorry – http://stackoverflow.com/questions/5228030/specflow-re-usable-step-definitions Unit Tests                    – used MBUnit with Resharper for testing TDD style

Design

I choose to use a state pattern for managing the order object. As the order transitions to completed I choose to use chained commands to execute tasks So once the order is complete the following commands are kicked off:
– CalculateSalesTaxCommand
– CalculateImportedSalesTaxCommand
– CalculateTotalCostWithoutTaxCommand

Probably YAGNI but this is just a demo

Other

NuGet used to get the following packages. Args, Command Line processor, Castle.Core Castle.Windsor, Rhino Mocks, MBUnit, SpecFlow SoftwareApproach.TestingExtensions, VisualStudioTestingExtensions.1.1.0.0

SystemWrapper used to wrap all IO calls for ease of testing – http://systemwrapper.codeplex.com/

The order can be seen below – its contains a CurrentState object that manages he order process:

public class Order {
  public Order()
  {
      OrderItems = new List<OrderItem\>();
      this.CurrentState = new NotCreated(this);
  }

  public virtual int Id { get; set; }
  public virtual string Name { get; set; }
  public IList<OrderItem\> OrderItems { get; set; }
  public virtual IOrderState CurrentState { get; set; }
  public Decimal TotalTax { get; set; }
  public Decimal TotalCostWithoutTax { get; set; }
  public decimal TotalOrderCost
  {
      get { return TotalTax + TotalCostWithoutTax; }
  }

  public virtual void AddProduct(Product product)
  {
      AddProduct(product, 1);
  }

  public virtual void AddProduct(Product product, int quantity)
  {
      var oi = new OrderItem { Product = product, Quantity = quantity };

      if (OrderItems.Any(a=>a.Product.Id == product.Id))
      {
          int originalQuantity = OrderItems.Single(a => a.Product.Id == product.Id).Quantity;
          OrderItems.Single(a => a.Product.Id == product.Id).Quantity = (originalQuantity + quantity);
      }
      else {
          OrderItems.Add(oi);   
      }
  }

  public virtual void Become(IOrderState orderState)
  {
      orderState.BecomeCommands.Execute();
      CurrentState.Become(orderState);
  }   }

 

The states the order can be in are:

  • NotCreated
  • Created
  • Completed

As the order moves to its next stage a chain of commands is kicked off.

  • NotCreated  –  NoCommand (does nothing)
  • Created       –  NoCommand (does nothing)
  • Completed   – CalculateSalesTaxCommand, CalculateImportedSalesTaxCommand, CalculateTotalCostWithoutTaxCommand –> run in order these do the calculations

In starting development, I started with the BDD Acceptance test – this runs red so then I develop unit tests which after red/green/refactor I rerun the acceptance test which goes green

Here is the Spec flow feature file for the order creation

Feature: Customer Order Creation To create an order As a user I enter the customers details And the Order is created

@CustomerOrderCreation Scenario: Create an order Given a Customer When the Customers name is Test Customer When I create an order Then a new order is created

Ands here the BDD code file

[Binding]
 public class CustomerOrderCreationFlow {
   protected Customer customer;
   protected IOrderService orderService;
   protected Order order;

   [Given(@"a Customer")\]
   public void GivenACustomer()
   {
       customer = new Customer();
   }

   [When(@"I create an order")\]
   public void WhenICreateAnOrder()
   {
       orderService = new OrderService();
       order = orderService.Create(customer);
   }

   [When(@"the Customers name is Test Customer")\]
   public void WhenTheCustomersNameIsTestCustomer()
   {
       customer.FirstName = "Test";
       customer.LastName = "Customer";
   }

   [Then(@"a new order is created")\]
   public void ThenANewOrderIsCreated()
   {
       order.ShouldNotBeNull("Order is null");
       order.CurrentState.ShouldBeOfType(typeof (Created));
   }

Adding orders

Feature: Manage an order As a user I want to change the customer order I add a product Then an order item is added to the order

@AddSingleProduct Scenario: Add single product to an order Given A Customer with Name Joe Smith Given an order When I add a product Then an order item should be added

@AddMultipleProducts Scenario: Add Multiple Products to an Order Given A Customer with Name Joe Smith Given an order When I add the following products | Id | Name | ProductType | Imported | Price | | 1 | A-Book | Book | True | 33.45 | | 2 | CD | Medical | false | 15.00 | | 3 | Chocolates | Food | true | 5.00 | | 4 | Gym Socks | Other | false | 3.00 | Then an order item should be added

And the code:

[Binding]
public class OrderInProgressFlow {
    private Order order;
    private IOrderService orderService;
    private Customer customer;
    private Product product;
    private IList<Product\> tableProducts;

    [Given(@"an order")]
    public void GivenAnOrder()
    {
        var stubOrder = new Order()
        {
            Name = string.Format("Order for {0} {1}", customer.FirstName, customer.LastName),
        };
        stubOrder.CurrentState = new Created(stubOrder);
        orderService = MockRepository.GenerateMock<IOrderService\>();
        orderService.Stub(a => a.Create(customer)).Return(stubOrder);
        order = orderService.Create(customer);
    }

    [Given(@"A Customer with Name Joe Smith")]
    public void GivenACustomerWithNameJoeSmith()
    {
        customer = new Customer {FirstName = "Joe", LastName = "Smith"};
    }

    [When(@"I add a product")\]
    public void WhenIAddAProduct()
    {
        product = new ProductBuilder()
            .WithId(1)
            .WithPrice(5)
            .WithName("AAA")
            .WithProductType(ProductType.Other)
            .Build();

        order.AddProduct(product);
    }

    [Then(@"an order item should be added")]
    public void ThenAnOrderItemShouldBeAdded()
    {
        if (product != null)
        {
            order.OrderItems.Any(a => a.Product.Id == product.Id).ShouldBeTrue();    
        }
        else {
            tableProducts.Each(a => order.OrderItems.Any(b => b.Product.Id == a.Id).ShouldBeTrue());
        }
        
    }

    [When(@"I add the following products")]
    public void WhenIAddTheFollowingProducts(Table table)
    {
        tableProducts = new List<Product\>();
        SpecFlowHelpers.ConvertTableToProductList(table);
        tableProducts.Each(order.AddProduct);
    }

And finally the big one – Order Completion

Feature: Sales tax should be 10% for all goods except books food and medical products
When an order is completed
As a user
I want to calculate the sales taxes

@CalculateSalesTax1 Scenario: Calculate Sales Tax
Given A Customer with Name Joe Smith
Given an order that is in progress
When I add the following products to the inprogress order

Id Name ProductType Imported Price
1 A-Book Book false 12.49
2 Music CD Other false 14.99
3 Chocolates Food false 00.85
When the order is completed
Then the sales tax should be 1.50 
And the total should be 29.83

@CalculateSalesTax2 Scenario: Calculate Sales Tax for imported items
Given A Customer with Name Joe Smith
Given an order that is in progress
When I add the following imported products to the inprogress order

Id Name ProductType Imported Price
1 Chocolates Food true 10.00
2 Perfume CD Other true 47.50
When the order is completed
Then the sales tax should be 7.65 

@CalculateSalesTax3 Scenario: Calculate Sales Tax for both types of items
Given A Customer with Name Joe Smith
Given an order that is in progress
When I add the following imported products to the inprogress order

Id Name ProductType Imported Price
1 perfume Other true 27.99
2 perfume Other false 18.99
3 pills Medical false 9.75
4 chocolate Food true 11.25

When the order is completed
Then the sales tax should be 6.70

And the code:

Note: Really this should be refactored into different re-usable steps and separate feature files – see this http://stackoverflow.com/questions/5228030/specflow-re-usable-step-definitions

[Binding]
public class OrderCompleteFlow {
    private Order order;
    private IOrderService orderService;
    private Customer customer;
    private IList<Product\> tableProducts;

    [Given(@"an order thats in progress")\]
    public void GivenAnOrderThatsInProgress()
    {
        customer = MockRepository.GenerateStub<Customer\>();
        order = new Order()
        {
            Name = string.Format("Order for {0} {1}", customer.FirstName, customer.LastName),
        };
        order.CurrentState = new Created(order);
        
    }

    [When(@"I add the following products to the inprogress order")\]
    public void WhenIAddTheFollowingProductsToTheInprogressOrder(Table table)
    {
        AddSpecFlowTableProductsToProductList(table);
    }

    private void AddSpecFlowTableProductsToProductList(Table table)
    {
        tableProducts = new List<Product\>();
        tableProducts = SpecFlowHelpers.ConvertTableToProductList(table);
        tableProducts.Each(order.AddProduct);
    }

    [When(@"I add the following imported products to the inprogress order")\]
    public void WhenIAddTheFollowingImportedProductsToTheInprogressOrder(Table table)
    {
        AddSpecFlowTableProductsToProductList(table);
    }

    [When(@"the order is completed")\]
    public void WhenTheOrderIsCompleted()
    {
        orderService = IocContainer.Instance.Resolve<IOrderService\>();
        orderService.Complete(order);
    }

    [Then(@"the sales tax should be 1\\.50")\]
    public void ThenTheSalesTaxShouldBe1\_50()
    {
        order.TotalTax.ShouldEqual(1.5m);
    }

    [Then(@"the total should be 29\\.83")\]
    public void ThenTheTotalShouldBe29\_83()
    {
        order.TotalOrderCost.ShouldEqual(29.83m);
    }

    [Then(@"the sales tax should be 7\\.65")\]
    public void ThenTheSalesTaxShouldBe7\_65()
    {
        order.TotalTax.ShouldEqual(7.65m);
    }

    [Then(@"the sales tax should be 6\\.70")\]
    public void ThenTheSalesTaxShouldBe6\_70()
    {
        order.TotalTax.ShouldEqual(6.70m);
    }

}

 

Download the code here


Published:

Share on Twitter