So we've got something approximating an e-commerce system. We have products with photos, descriptions, and prices, and you can add them to a cart as a guest. Next we're going to add the ability to actually complete an order. Let's get started.

Project

Obviously we need an Order model. An Order's just like a Cart, but it's been completed. Consequently we would expect to be able to call some function on a Cart, and end up with a new Order in our database and an empty Cart. Let's model this with unit tests.

We're going to introduce a new model into our system. It's the Register, and it's where a Cart gets ordered. We'll start out writing a basic test:

# We can read in the Acceptance.CartTest and tweak it some for this
defmodule PhoenixCommerce.RegisterTest do
  use PhoenixCommerce.ModelCase
  alias PhoenixCommerce.{Product, LineItem, Cart, Register, Order}

  setup do
    Repo.delete_all(Cart)
    Repo.delete_all(LineItem)
    Repo.delete_all(Product)
    {:ok, product} =
      Product.changeset(%Product{}, %{
        name: "Some product",
        description: "Some product description",
        price: Decimal.new("25.20")
      }) |> Repo.insert

    {:ok, cart} =
      Cart.changeset(%Cart{}, %{})
      |> Repo.insert

    {:ok, _line_item} =
      LineItem.changeset(%LineItem{}, %{
        product_id: product.id,
        cart_id: cart.id,
        quantity: 1
      }) |> Repo.insert

    {:ok, cart: cart}
  end

  test "ordering a cart introduces a new order with the cart's line items", %{cart: cart} do
    assert {:ok, %Order{}} = Register.order(cart)
  end
end

So here's the basic interface we want to honor. Passing a cart to Register.order yields a 2-tuple containing the atom :ok, and the Order struct. Of course running this test fails because there's presently neither a Register or an Order module. We can generate an order model.

mix phoenix.gen.model Order orders
defmodule PhoenixCommerce.Repo.Migrations.CreateOrder do
  use Ecto.Migration

  def change do
    create table(:orders) do
      timestamps
    end

    # We'll also add a field to line items to reference an order
    alter table(:line_items) do
      add :order_id, references(:orders)
    end
  end
end
defmodule PhoenixCommerce.Order do
  use PhoenixCommerce.Web, :model

  schema "orders" do
    has_many :line_items, PhoenixCommerce.LineItem

    timestamps
  end

  @required_fields ~w()
  @optional_fields ~w()

  @doc """
  Creates a changeset based on the `model` and `params`.

  If no params are provided, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
  end
end

We'll make sure line items know they belong to orders as well.

defmodule PhoenixCommerce.LineItem do
  #...
  schema "line_items" do
    #...
    belongs_to :cart, PhoenixCommerce.Cart
    belongs_to :order, PhoenixCommerce.Order
    #...
  end
  @required_fields ~w(product_id quantity)
  @optional_fields ~w(cart_id order_id)
  #...
end

Now we can define the Register module.

defmodule PhoenixCommerce.Register do
  alias PhoenixCommerce.{Cart, LineItem, Order, Repo}
  import Ecto.Query

  # the `order` function should return a 2-tuple containing the atom :ok and the
  # Order, or an error tuple with a reason.
  @spec order(%Cart{}) :: {:ok, %Order{}} | {:error, String.t}
  def order(cart=%Cart{}) do
    # We'll insert a new order
    order =
      Order.changeset(%Order{}, %{})
      |> Repo.insert!

    # Update each of the cart's line items to point to this order
    line_items =
      from li in LineItem,
      where: li.cart_id == ^cart.id

    {_count, _returnval} =
      line_items
      |> Repo.update_all(set: [cart_id: nil, order_id: order.id])

    # Then return the order.
    {:ok, order}
  end
end

If we run this, it will work, but there are no transactions - it's a bad idea. We can end up with a cart without the line items being transferred. Let's wrap this in a transaction:

defmodule PhoenixCommerce.Register do
  alias PhoenixCommerce.{Cart, LineItem, Order, Repo}
  import Ecto.Query

  @spec order(%Cart{}) :: {:ok, %Order{}} | {:error, String.t}
  def order(cart=%Cart{}) do
    # We can just wrap in a call to the Repo.transaction function, passing in a
    # fun for it to execute inside the transaction.
    Repo.transaction(fn() ->
      order =
        Order.changeset(%Order{}, %{})
        |> Repo.insert!

      line_items =
        from li in LineItem,
        where: li.cart_id == ^cart.id

      {_count, _returnval} =
        line_items
        |> Repo.update_all(set: [cart_id: nil, order_id: order.id])

      order
    end)
  end
end

Run the tests...so that works, but next we want to make sure that we end up with the line items on the order. Let's add an assertion for that:

  test "ordering a cart introduces a new order with the cart's line items", %{cart: cart} do
    assert {:ok, order=%Order{}} = Register.order(cart)
    assert 1 = length(order.line_items)
  end

Alright, now this will fail because the line items aren't preloaded. We'll go ahead and preload them in the Register.order function - this may not be the best idea, but it works for now.

  @spec order(%Cart{}) :: {:ok, %Cart{}} | {:error, String.t}
  def order(cart=%Cart{}) do
    Repo.transaction(fn() ->
      #...
      order = Repo.preload(order, :line_items)
      order
    end)
  end

Now the test passes. So we have orders, and they have line items.

Summary

In this episode we added orders and a little business logic to check out a cart and turn it into an order. We also saw briefly how to use transactions in Ecto. In the next episode, we're going to integrate with stripe so people can pay us for an order. See you soon!

Resources