How to handle an amount-field that is given as string, transformed and stored as integer

Multiple places in my application, I have changesets where one or multiple of the fields represents an amount. In the field in the form, they should be able to write the amount as a string e.g. “12”, “,3”, “1 234,5” etc. In the database, it should be stored as in cents (so *10) and as :integer, so “1200”, “30” and “123450”.

The problem is when to handle the transformation from string to integer and in cents. Currently I am seeing two options, but not sure if either of them are good or if there are better ways to do it.

  1. Using AlpineJs and having a hidden field, where I transform the inputv-alue to an integer that is stored in the hidden field, and then used in the changeset. The problem here is that I have some validations in the changeset and some outside of it, and would like to not have to involve AlpineJs.

  2. Having a virtual field for each amount-field, and where the transformation from string to integer and validation is done in the changeset.
    The problem here is that I would need one virtual field for each amount field, which would be more messy, I feel.

I am using Liveview, so each the a field is changed, the params from the forms are given to the changeset. Hence it is important to not transform a value that is already changed again. For example if typing “12”, it should be transformed to “1200” to be stored in the database upon insert/update. But if another field is changed, it should not be transformed again to “120000”.

I’m using Elixir 1.14, Phoenix 1.7.0 and Phoenix Liveview 0.18.18.

Anyone who have thoughts about other ways to do it?

Option 3.

Write your own Ecto.Type that will do the conversion for you automatically.

2 Likes

I just whipped up an example and would do something with custom Ecto Type like this:

  schema "accounts" do
    ...
    field :amount, Stipy.EctoHelpers.RawCurrencyType
  end
defmodule Stipy.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

And have the tests passing like:

defp make_account(attrs) do
  Accounts.change_account(%Account{}, attrs)
  |> Ecto.Changeset.apply_changes()
end

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

Thank you for the tip. Based on it, I ended up using Money