Skip to content

Instantly share code, notes, and snippets.

@pauldambra
Created December 29, 2015 15:33
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pauldambra/05577be969f351c39c9c to your computer and use it in GitHub Desktop.
Save pauldambra/05577be969f351c39c9c to your computer and use it in GitHub Desktop.
the checkout kata in elixir
defmodule Checkout do
defstruct [
basket: %{A: 0, B: 0, C: 0, D: 0}
]
def scan(checkout, code) do
new_value = checkout.basket[code] + 1
%{checkout | basket: Map.put(checkout.basket, code, new_value)}
end
def total(checkout) do
(for item <- checkout.basket,
do: get_price item)
|> Enum.sum
end
defp get_price({:A, amount}), do: get_special_price(%{amount: 3, price: 130}, 50, amount, 0)
defp get_price({:B, amount}), do: get_special_price(%{amount: 2, price: 45}, 30, amount, 0)
defp get_price({:C, amount}), do: get_price(20, amount)
defp get_price({:D, amount}), do: get_price(15, amount)
defp get_price(cost, amount) do
cost * amount
end
defp get_special_price(offer, normal_cost, amount, current_total) do
if amount >= offer.amount do
get_special_price(offer, normal_cost, amount - offer.amount, current_total + offer.price)
else # amount is now either less than the offer anmount or 0
current_total + get_price(normal_cost, amount)
end
end
end
defmodule CheckoutTest do
use ExUnit.Case
doctest Checkout
# Item Unit Special
# Price Price
# --------------------------
# A 50 3 for 130
# B 30 2 for 45
# C 20
# D 15
setup do
scans = [
%{items: [:A], total: 50},
%{items: [:B], total: 30},
%{items: [:C], total: 20},
%{items: [:D], total: 15},
%{items: [:A, :A], total: 100},
%{items: [:B, :B], total: 45},
%{items: [:A, :B], total: 80},
%{items: [:A, :A, :A], total: 130},
%{items: [:A, :A, :A, :A, :A], total: 230},
%{items: [:A, :A, :A, :B, :B], total: 175},
]
{:ok, data: scans}
end
test "scanning an item adds it to the basket" do
checkout = Checkout.scan %Checkout{}, :A
assert checkout.basket == %{A: 1, B: 0, C: 0, D: 0}
end
test "totalling a checkout can give a result" do
checkout = Checkout.scan %Checkout{}, :A
assert Checkout.total(checkout) == 50
end
test "scanning a basket gives expected total cost", %{data: scans} do
Enum.each(scans,
fn(scan) ->
total = Enum.reduce(
scan.items,
%Checkout{},
fn(item, chkot) -> Checkout.scan(chkot, item) end
)
|> Checkout.total
assert total == scan.total
end
)
end
end
@robconery
Copy link

So I kind of went off here a little bit - hopefully it makes sense what I did. The primary thing is that I refactored for clarity but also changed some concepts. You're using the terms cost and price interchangeably which is confusing and the special_price is a calculated thing - not raw data. Anyway - here's the refactor, the tests pass I think as you would want.

defmodule Checkout do

  def scan(item) when is_map(item), do: scan(item, [])
  #convenience for passing in multiple items
  def scan(items) when is_list(items), do: Enum.reduce(items, [], &(scan(&1, &2)))
  def scan(item, order) when is_list(order), do: [decide_discount(item) | order]

  def total(cart) do
    Enum.reduce cart, 0, fn(item, running_total) ->
      (item.price-item.discount) * item.quantity + running_total
    end
  end

  def subtotal(cart) do
    Enum.reduce cart, 0, fn(item, running_total) ->
      (item.price * item.quantity) + running_total
    end
  end

  defp decide_discount(item) do
    criteria = {item.sku, item.quantity}
    discount = case criteria do
      {:A, 3} -> 20.00
      {:B, 2} -> 15.00
      criteria -> 0 #default
    end
    Map.put_new(item, :discount, discount)
  end
end


defmodule CheckoutTest do
  use ExUnit.Case

  setup do
    items = [
      %{sku: :A, price: 50.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 4},
      %{sku: :C, price: 20.00, quantity: 1},
      %{sku: :A, price: 50.00, quantity: 3},
      %{sku: :B, price: 30.00, quantity: 1}
    ]
    {:ok, cart: items}
  end

  test "Scanning first item creates order", %{cart: items} do
    [first | _ ] = items
    order = Checkout.scan(first)
    assert length(order) == 1
  end

  test "Scanning multiple items appends to order", %{cart: items} do
    order = for item <- items, do: Checkout.scan(item)
    assert length(order) == 5
  end

  test "Scanning a list of items returns the list", %{cart: items} do
    order = Checkout.scan(items)
    assert length(order) == 5
  end

  test "The cart is subtotaled without discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.subtotal
    assert total == 370.00
  end

  test "The cart is totaled with discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.total
    assert total == 310.00
  end

end

@robconery
Copy link

Also, notice the use of Enum.reduce - it will do a map and reduce at the same time. I'm using it in a couple of places as it fits perfectly. Also note how smaller functions drive clarity (to me, at least).

In the totaling tests notice how its the result of a process: scanning and then totaling. This is a neat thing about FP - it forces you to think in terms of pipes, processes and transforms. You can't total items you haven't scanned :).

@robconery
Copy link

... and I just read that this is a kata. Ugh - I'll have a look at it and give you some better feedback...

@tomliversidge
Copy link

Very much prefer the splitting out of subtotal and total.. But shouldnt the total after the discount be 320? The total is 370, then there is a saving of 20 for having 4 As and two savings of 15 for 5 Bs so 50 in total.. So 370-50=320, or have i missed something?

@michalmuskala
Copy link

Since I quite enjoy kata refactorings, I took @robconery's version and refactored it yet another bit.

The biggest difference is probably extracting separate function for deciding discounts and naming that part of code, as well as abstracting counting a total with the priced_total/2 function parametrized by a function used to calculate price.

defmodule Checkout do
  def scan(items) when is_list(items), do: Enum.map(items, &decide_discount/1)
  def scan(item)  when is_map(item),   do: [decide_discount(item)]

  defp decide_discount(item) do
    Map.put(item, :discount, discount_for(item.sku, item.quantity))
  end

  defp discount_for(:A, 3), do: 20.00
  defp discount_for(:B, 2), do: 15.00
  defp discount_for(_, _),  do:  0.00

  def total(cart),    do: priced_total(cart, &discount_price/1)
  def subtotal(cart), do: priced_total(cart, &normal_price/1)

  defp discount_price(item), do: (item.price - item.discount) * item.quantity
  defp normal_price(item),   do: item.price * item.quantity

  defp priced_total(cart, price_fun) do
    cart
    |> Enum.map(price_fun)
    |> Enum.sum
  end
end

ExUnit.start

defmodule CheckoutTest do
  use ExUnit.Case

  test "Scanning first item creates order" do
    [first | _] = items
    order = Checkout.scan(first)
    assert length(order) == 1
  end

  test "Scanning multiple items appends to order" do
    order = for item <- items, do: Checkout.scan(item)
    assert length(order) == 5
  end

  test "Scanning a list of items returns the list" do
    order = Checkout.scan(items)
    assert length(order) == 5
  end

  test "The cart is subtotaled without discount" do
    total = Checkout.scan(items) |> Checkout.subtotal
    assert total == 370.00
  end

  test "The cart is totaled with discount" do
    total = Checkout.scan(items) |> Checkout.total
    assert total == 310.00
  end

  defp items do
    [
      %{sku: :A, price: 50.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 4},
      %{sku: :C, price: 20.00, quantity: 1},
      %{sku: :A, price: 50.00, quantity: 3},
      %{sku: :B, price: 30.00, quantity: 1}
    ]
  end
end

@pauldambra
Copy link
Author

Thanks all! Lots to absorb...

@tomliversidge
Copy link

I think the problem is that is is adding the discount for each item, rather than the set. For example, the second test fails:

defmodule CheckoutTest do
  use ExUnit.Case

  setup do
    items = [
      %{sku: :A, price: 50.00, quantity: 3}
    ]
    {:ok, cart: items}
  end

  test "The cart is subtotaled without discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.subtotal
    assert total == 150.00
  end

  test "The cart is totaled with discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.total
    assert total == 130.00
  end

end

Result of the second test is 90

@tomliversidge
Copy link

I'm not sure how to do it yet, but this is what I'm aiming for:

  def total(cart) do
    subtotal(cart) - discount(cart)
  end

  def discount(cart) do
    #calculate the discount
  end

where the discount function calculates the discount on the items in the cart. This would mean changing the scan function to not calculate the discount:

  def scan(item, order) when is_list(order), do: [item | order]

@tomliversidge
Copy link

This is what I came up with:

defmodule Checkout do

  def scan(item) when is_map(item), do: scan(item, [])
  #convenience for passing in multiple items
  def scan(items) when is_list(items), do: Enum.reduce(items, [], &(scan(&1, &2)))
  def scan(item, order) when is_list(order), do: [item | order]

  def total(cart) do
    subtotal(cart) - discount(cart)
  end

  def discount(cart) do
    #calculate the discount
    calculate_discount(cart, :A, 3, 20.00) + calculate_discount(cart, :B, 2, 15.00)
  end

  defp calculate_discount(cart, identifier, discount_quantity, discount_value) do
    items = Enum.filter(cart, fn(item) ->
      item.sku == identifier
    end)
    total_items_count = Enum.reduce(items, 0, fn(item, acc) -> item.quantity + acc end)
    Float.floor(total_items_count / discount_quantity, 0) * discount_value
  end

  def subtotal(cart) do
    Enum.reduce cart, 0, fn(item, running_total) ->
      (item.price * item.quantity) + running_total
    end
  end

  defp decide_discount(item) do
    criteria = {item.sku, item.quantity}
    discount = case criteria do
      {:A, 3} -> 20.00
      {:B, 2} -> 15.00
      criteria -> 0 #default
    end
    Map.put_new(item, :discount, discount)
  end
end

defmodule CheckoutTest do
  use ExUnit.Case

  setup do
    items = [
      %{sku: :A, price: 50.00, quantity: 3},
      %{sku: :A, price: 50.00, quantity: 2},
      %{sku: :A, price: 50.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :C, price: 20.00, quantity: 1},
    ]
    {:ok, cart: items}
  end

  test "The cart is subtotaled without discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.subtotal
    assert total == 410.00
  end

  test "The cart is totaled with discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.total
    assert total == 355.00
  end

end

Main problem with it is that the rules are hard-coded inside the discount function

@tomliversidge
Copy link

Thinking about it, we should be able to pass in a list of rules to the discount function then just loop over them calling calculate_discount. Maybe.

@robconery
Copy link

Nice refactors! Yeah Tom I think having a discount callback with a set of rules would be interesting :).

@tomliversidge
Copy link

Ok, came up with:

defmodule Checkout do

  def scan(item) when is_map(item), do: scan(item, [])
  #convenience for passing in multiple items
  def scan(items) when is_list(items), do: Enum.reduce(items, [], &(scan(&1, &2)))
  def scan(item, order) when is_list(order), do: [item | order]

  def total(cart, offers) do
    subtotal(cart) - discount(cart, offers)
  end

  def discount(cart, offers) do
    Enum.reduce(offers, 0, fn(offer, total) -> calculate_discount(cart, offer) + total end)
  end

  defp calculate_discount(cart, offer) do
    cart
      |> filter_by(offer.sku)
      |> count
      |> calculate_discount(offer.discount_qualification_quantity, offer.discount)
  end

  defp filter_by(cart, sku) do
    Enum.filter(cart, &(&1.sku == sku))
  end

  defp count(items) do
    Enum.reduce(items, 0, fn(item, total) -> item.quantity + total end)
  end

  defp calculate_discount(items_count, discount_qualification_quantity, discount_value) do
    Float.floor(items_count / discount_qualification_quantity, 0) * discount_value
  end

  def subtotal(cart) do
    Enum.reduce cart, 0, fn(item, running_total) ->
      (item.price * item.quantity) + running_total
    end
  end

end

defmodule CheckoutTest do
  use ExUnit.Case

  setup do
    items = [
      %{sku: :A, price: 50.00, quantity: 3},
      %{sku: :A, price: 50.00, quantity: 2},
      %{sku: :A, price: 50.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :B, price: 30.00, quantity: 1},
      %{sku: :C, price: 20.00, quantity: 1},
    ]
    {:ok, cart: items}
  end

  test "The cart is subtotalled without discount", %{cart: items} do
    total = Checkout.scan(items) |> Checkout.subtotal
    assert total == 410.00
  end

  test "The cart is totalled with discounts based on default offers", %{cart: items} do
    offers = [
      %{sku: :A, discount_qualification_quantity: 3, discount: 20},
      %{sku: :B, discount_qualification_quantity: 2, discount: 15}
    ]
    total = Checkout.scan(items) |> Checkout.total(offers)
    assert total == 355.00
  end

  test "The cart is totalled with discounts based on different offers", %{cart: items} do
    offers = [
      %{sku: :A, discount_qualification_quantity: 3, discount: 25},
      %{sku: :B, discount_qualification_quantity: 2, discount: 20}
    ]
    total = Checkout.scan(items) |> Checkout.total(offers)
    assert total == 340.00
  end

end

Any suggestions welcome - this is my first attempt at writing any elixir, along with the Red:4 course! It's great fun so far!

Think the filter_by and count functions should possibly be one thing as what I want to know is how many of a certain sku are in the cart, just wasn't sure on the best way to write it. Could just stick it in one function, but wondered if Enum has anything build in that might do this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment