Tutorial

Handling Amount Fields in a Phoenix Application with Ecto Custom Types

forms ecto

In this tutorial, we will discuss a common problem faced in Elixir applications related to handling and storing formatted amount fields in a database. We will walk you through how to handle this problem using Ecto, a database wrapper and query generator in Elixir, along with its custom types feature.

Problem Statement

Let's first understand the problem we are trying to solve. In various parts of your application, you might have changesets where one or multiple fields represent an amount. These fields should allow users to input the amount as a string, such as "12", ",3", "1 234,5" etc. However, you want to store these values in the database as integers and in cents, i.e., multiplied by 10, so they would become "120", "30", "123450", etc.

The challenge here is deciding when and where to handle the transformation from a string to an integer, and how to multiply it by 10 to convert it into cents. In this tutorial, we will be using an Elixir library called Ecto and its custom types feature to solve this problem.

Solution Overview

We will be creating a Phoenix application with an Order module that includes an :amount field. This field will use a custom Ecto type called RawCurrencyType that we will define. This custom type will handle the conversion of the string input into an integer value in cents.

Defining the Order Schema

Let's start by defining the schema for our Order module. The schema will include an :amount field that uses our custom RawCurrencyType:

defmodule Tutorial.Orders.Order do
  @moduledoc """
  The order schema.
  """
  use Ecto.Schema
  import Ecto.Changeset

  schema "orders" do
    field :email, :string
    field :amount, Tutorial.EctoHelpers.RawCurrencyType

    timestamps()
  end

  @doc false
  def changeset(order, attrs) do
    order
    |> cast(attrs, [:email, :amount])
    |> validate_required([:email, :amount])
  end
end

In the above code, we have created a schema for an Order with fields for :email and :amount. We have defined the :amount field to use our RawCurrencyType custom Ecto type.

Creating the Custom Ecto Type

Now, let's define our RawCurrencyType:

defmodule Tutorial.EctoHelpers.RawCurrencyType do
  use Ecto.Type

  def type, do: :integer

  def cast(val) when is_integer(val), do: {:ok, val}

  def cast(val) do
    val = String.replace(val, " ", "")

    cond do
      String.contains?(val, ",") && String.contains?(val, ".") ->
        val
        |> transform_commas()
        |> convert_and_multiply()
      String.contains?(val, ",") ->
        val
        |> String.replace(",", "")
        |> (& "#{&1}.0").()
        |> convert_and_multiply()
      true ->
        convert_and_multiply(val)
    end
  end

  defp transform_commas(val) do
    val_as_list = String.split(val, "")

    if Enum.find_index(val_as_list, &(&1 == ",")) < Enum.find_index(val_as_list, &(&1 == ".")) do
      String.replace(val, ",", "")
    else
      val
      |> String.replace(".", "")
      |> String.replace(",", ".")
    end
  end

  defp convert_and_multiply(val) do
    val =
      if String.contains?(val, ".") do
        val
        |> String.to_float()
        |> Float.round(2)
        |> (& &1 * 100).()
        |> trunc()
      else
        String.to_integer(val)
      end

    {:ok, val}
  rescue
    _ ->
      {:error, "Could not be converted"}
  end

  def dump(val), do: {:ok, val}

  def load(val), do: {:ok, val}
end

In the RawCurrencyType module, we have defined various functions to handle our specific requirements. The type/0 function returns :integer, indicating that this type is represented as an integer in the database.

The cast/1 function handles the conversion from the input value to the appropriate integer value. If the value is already an integer, it is returned as is. Otherwise, it removes any spaces from the string, and then performs different transformations based on the presence of commas and periods. The input string is then converted to a float, rounded to two decimal places, multiplied by 100 (to represent the value in cents), and truncated to remove any remaining decimal places.

The dump/1 and load/1 functions are required by the Ecto.Type behaviour and just return the value as is, since we don't need to perform any transformations when storing the value in or loading it from the database.

Testing the Order Module

Lastly, let's create tests for our Order module to verify that our amount conversion works correctly:

defmodule Tutorial.OrdersTest do
  use Tutorial.DataCase

  alias Tutorial.Orders
  alias Tutorial.Orders.Order

  describe "orders" do
    defp make_order(attrs) do
      Orders.change_order(%Order{}, attrs)
      |> Ecto.Changeset.apply_changes()
    end

    test "stores currencies as cents" do
      assert make_order(%{amount: 100}).amount == 100
      assert make_order(%{amount: "100"}).amount == 100
      assert make_order(%{amount: "100.5"}).amount == 10_050
      assert make_order(%{amount: "10,000"}).amount == 1_000_000
      assert make_order(%{amount: "10,000.50"}).amount == 1_000_050
    end
  end
end

In the test module, we create an order with different amounts and assert that the amount is converted correctly to the expected integer value in cents.

That's it! With this approach, you can efficiently handle the conversion of formatted string amounts to integer values in cents in your Elixir application using Ecto's custom types.

Related Tutorials

Published 01 Jun - 2021

How to use Phoenix forms for embedded schema and JSONB

In this tutorial, I want to show you how you can setup form that stores the data in partly the columns in a database but also some of the fields in ..

Published 06 May - 2022

Teams Feature with Phx.Gen.Auth

A very common feature in web applications, especially SAAS applications are the concept of teams where a user can have and belong to multiple teams...