Tutorial
Handling Amount Fields in a Phoenix Application with Ecto Custom Types
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.