defmodule SoftBank.Note do import Kernel, except: [abs: 1] @moduledoc """ Defines a `SoftBank.Note` struct along with convenience methods for working with currencies. ## Example: iex> note = SoftBank.Note.new(500, :USD) %SoftBank.Note{amount: 500, currency: :USD} iex> note = SoftBank.Note.add(note, 550) %SoftBank.Note{amount: 1050, currency: :USD} iex> SoftBank.Note.to_string(note) "$10.50" ## Configuration options You can set defaults in your Mix configuration to make working with `SoftBank.Note` a little easier. ## Configuration: config :note, default_currency: :EUR, # this allows you to do SoftBank.Note.new(100) separator: ".", # change the default thousands separator for SoftBank.Note.to_string delimiter: ",", # change the default decimal delimeter for SoftBank.Note.to_string symbol: false # don’t display the currency symbol in SoftBank.Note.to_string symbol_on_right: false, # position the symbol symbol_space: false # add a space between symbol and number fractional_unit: false # don’t display the remainder or the delimeter """ @type t :: %__MODULE__{ amount: Money.Ecto.Composite.Type, currency: atom } defstruct amount: Money.new(0, :USD), currency: Application.get_env(:note, :default_currency) alias SoftBank.Note.Currency @spec new(integer) :: t @doc ~S""" Create a new `SoftBank.Note` struct using a default currency. The default currency can be set in the system Mix config. ## Example Config: config :note, default_currency: :USD ## Example: SoftBank.Note.new(123) %SoftBank.Note{amount: 123, currency: :USD} """ def new(amount) do currency = Application.get_env(:note, :default_currency) if currency do new(amount, currency) else raise ArgumentError, "to use SoftBank.Note.new/1 you must set a default currency in your application config." end end @spec new(integer, atom | String.t()) :: t @doc """ Create a new `SoftBank.Note` struct from currency sub-units (cents) ## Example: iex> SoftBank.Note.new(1_000_00, :USD) %SoftBank.Note{amount: 1_000_00, currency: :USD} """ def new(int, currency) when is_integer(int), do: %SoftBank.Note{amount: int, currency: Currency.to_atom(currency)} @spec parse(String.t() | float, atom | String.t(), Keyword.t()) :: {:ok, t} @doc ~S""" Parse a value into a `SoftBank.Note` type. The following options are available: - `separator` - default `","`, sets the separator for groups of thousands. "1,000" - `delimeter` - default `"."`, sets the decimal delimeter. "1.23" ## Examples: iex> SoftBank.Note.parse("$1,234.56", :USD) {:ok, %SoftBank.Note{amount: 123456, currency: :USD}} iex> SoftBank.Note.parse("1.234,56", :EUR, separator: ".", delimeter: ",") {:ok, %SoftBank.Note{amount: 123456, currency: :EUR}} iex> SoftBank.Note.parse("1.234,56", :WRONG) :error iex> SoftBank.Note.parse(1_234.56, :USD) {:ok, %SoftBank.Note{amount: 123456, currency: :USD}} iex> SoftBank.Note.parse(-1_234.56, :USD) {:ok, %SoftBank.Note{amount: -123456, currency: :USD}} """ def parse(value, currency \\ nil, opts \\ []) def parse(value, nil, opts) do currency = Application.get_env(:note, :default_currency) if currency do parse(value, currency, opts) else raise ArgumentError, "to use SoftBank.Note.new/1 you must set a default currency in your application config." end end def parse(str, currency, opts) when is_binary(str) do try do {_separator, delimeter} = get_parse_options(opts) value = str |> prepare_parse_string(delimeter) |> add_missing_leading_digit case Float.parse(value) do {float, _} -> parse(float, currency, []) :error -> :error end rescue _ -> :error end end def parse(float, currency, _opts) when is_float(float) do {:ok, Money.new(round(float * 100), currency)} end defp prepare_parse_string(characters, delimeter, acc \\ []) defp prepare_parse_string([], _delimeter, acc), do: Enum.reverse(acc) |> Enum.join() defp prepare_parse_string(["-" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["-" | acc]) defp prepare_parse_string(["0" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["0" | acc]) defp prepare_parse_string(["1" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["1" | acc]) defp prepare_parse_string(["2" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["2" | acc]) defp prepare_parse_string(["3" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["3" | acc]) defp prepare_parse_string(["4" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["4" | acc]) defp prepare_parse_string(["5" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["5" | acc]) defp prepare_parse_string(["6" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["6" | acc]) defp prepare_parse_string(["7" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["7" | acc]) defp prepare_parse_string(["8" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["8" | acc]) defp prepare_parse_string(["9" | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["9" | acc]) defp prepare_parse_string([delimeter | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, ["." | acc]) defp prepare_parse_string([_head | tail], delimeter, acc), do: prepare_parse_string(tail, delimeter, acc) defp prepare_parse_string(string, delimeter, _acc), do: prepare_parse_string(String.codepoints(string), delimeter) defp add_missing_leading_digit(<<"-.">> <> tail), do: "-0." <> tail defp add_missing_leading_digit(<<".">> <> tail), do: "0." <> tail defp add_missing_leading_digit(str), do: str @spec parse(String.t() | float, atom | String.t(), Keyword.t()) :: t @doc ~S""" Parse a value into a `SoftBank.Note` type. Similar to `parse/3` but returns a `%SoftBank.Note{}` or raises an error if parsing fails. ## Example: iex> SoftBank.Note.parse!("1,234.56", :USD) %SoftBank.Note{amount: 123456, currency: :USD} iex> SoftBank.Note.parse!("wrong", :USD) ** (ArgumentError) unable to parse "wrong" """ def parse!(value, currency \\ nil, opts \\ []) do case parse(value, currency, opts) do {:ok, note} -> note :error -> raise ArgumentError, "unable to parse #{inspect(value)}" end end @spec compare(t, t) :: t @doc ~S""" Compares two `SoftBank.Note` structs with each other. They must each be of the same currency and then their amounts are compared ## Example: iex> SoftBank.Note.compare(SoftBank.Note.new(100, :USD), SoftBank.Note.new(100, :USD)) 0 iex> SoftBank.Note.compare(SoftBank.Note.new(100, :USD), SoftBank.Note.new(101, :USD)) -1 iex> SoftBank.Note.compare(SoftBank.Note.new(101, :USD), SoftBank.Note.new(100, :USD)) 1 """ def compare(%SoftBank.Note{currency: cur} = a, %SoftBank.Note{currency: cur} = b) do case a.amount - b.amount do x when x > 0 -> 1 x when x < 0 -> -1 x when x == 0 -> 0 end end def compare(a, b), do: fail_currencies_must_be_equal(a, b) @spec zero?(t) :: boolean @doc ~S""" Returns true if the amount of a `SoftBank.Note` struct is zero ## Example: iex> SoftBank.Note.zero?(SoftBank.Note.new(0, :USD)) true iex> SoftBank.Note.zero?(SoftBank.Note.new(1, :USD)) false """ def zero?(%SoftBank.Note{amount: amount}) do amount == 0 end @spec positive?(t) :: boolean @doc ~S""" Returns true if the amount of a `SoftBank.Note` is greater than zero ## Example: iex> SoftBank.Note.positive?(SoftBank.Note.new(0, :USD)) false iex> SoftBank.Note.positive?(SoftBank.Note.new(1, :USD)) true iex> SoftBank.Note.positive?(SoftBank.Note.new(-1, :USD)) false """ def positive?(%SoftBank.Note{amount: amount}) do amount > 0 end @spec negative?(t) :: boolean @doc ~S""" Returns true if the amount of a `SoftBank.Note` is less than zero ## Example: iex> SoftBank.Note.negative?(SoftBank.Note.new(0, :USD)) false iex> SoftBank.Note.negative?(SoftBank.Note.new(1, :USD)) false iex> SoftBank.Note.negative?(SoftBank.Note.new(-1, :USD)) true """ def negative?(%SoftBank.Note{amount: amount}) do amount < 0 end @spec equals?(t, t) :: boolean @doc ~S""" Returns true if two `SoftBank.Note` of the same currency have the same amount ## Example: iex> SoftBank.Note.equals?(SoftBank.Note.new(100, :USD), SoftBank.Note.new(100, :USD)) true iex> SoftBank.Note.equals?(SoftBank.Note.new(101, :USD), SoftBank.Note.new(100, :USD)) false """ def equals?(%SoftBank.Note{amount: amount, currency: cur}, %SoftBank.Note{ amount: amount, currency: cur }), do: true def equals?(%SoftBank.Note{currency: cur}, %SoftBank.Note{currency: cur}), do: false def equals?(a, b), do: fail_currencies_must_be_equal(a, b) @spec neg(t) :: t @doc ~S""" Returns a `SoftBank.Note` with the amount negated. ## Examples: iex> SoftBank.Note.new(100, :USD) |> SoftBank.Note.neg %SoftBank.Note{amount: -100, currency: :USD} iex> SoftBank.Note.new(-100, :USD) |> SoftBank.Note.neg %SoftBank.Note{amount: 100, currency: :USD} """ def neg(%SoftBank.Note{amount: amount, currency: cur}), do: %SoftBank.Note{amount: -amount, currency: cur} @spec abs(t) :: t @doc ~S""" Returns a `SoftBank.Note` with the arithmetical absolute of the amount. ## Examples: iex> SoftBank.Note.new(-100, :USD) |> SoftBank.Note.abs %SoftBank.Note{amount: 100, currency: :USD} iex> SoftBank.Note.new(100, :USD) |> SoftBank.Note.abs %SoftBank.Note{amount: 100, currency: :USD} """ def abs(%SoftBank.Note{amount: amount, currency: cur}), do: %SoftBank.Note{amount: Kernel.abs(amount), currency: cur} @spec add(t, t | integer | float) :: t @doc ~S""" Adds two `SoftBank.Note` together or an integer (cents) amount to a `SoftBank.Note` ## Example: iex> SoftBank.Note.add(SoftBank.Note.new(100, :USD), SoftBank.Note.new(50, :USD)) %SoftBank.Note{amount: 150, currency: :USD} iex> SoftBank.Note.add(SoftBank.Note.new(100, :USD), 50) %SoftBank.Note{amount: 150, currency: :USD} iex> SoftBank.Note.add(SoftBank.Note.new(100, :USD), 5.55) %SoftBank.Note{amount: 655, currency: :USD} """ def add(%SoftBank.Note{amount: a, currency: cur}, %SoftBank.Note{amount: b, currency: cur}), do: SoftBank.Note.new(a + b, cur) def add(%SoftBank.Note{amount: amount, currency: cur}, addend) when is_integer(addend), do: SoftBank.Note.new(amount + addend, cur) def add(%SoftBank.Note{} = m, addend) when is_float(addend), do: add(m, round(addend * 100)) def add(a, b), do: fail_currencies_must_be_equal(a, b) @spec subtract(t, t | integer | float) :: t @doc ~S""" Subtracts one `SoftBank.Note` from another or an integer (cents) from a `SoftBank.Note` ## Example: iex> SoftBank.Note.subtract(SoftBank.Note.new(150, :USD), SoftBank.Note.new(50, :USD)) %SoftBank.Note{amount: 100, currency: :USD} iex> SoftBank.Note.subtract(SoftBank.Note.new(150, :USD), 50) %SoftBank.Note{amount: 100, currency: :USD} iex> SoftBank.Note.subtract(SoftBank.Note.new(150, :USD), 1.25) %SoftBank.Note{amount: 25, currency: :USD} """ def subtract(%SoftBank.Note{amount: a, currency: cur}, %SoftBank.Note{amount: b, currency: cur}), do: SoftBank.Note.new(a - b, cur) def subtract(%SoftBank.Note{amount: a, currency: cur}, subtractend) when is_integer(subtractend), do: SoftBank.Note.new(a - subtractend, cur) def subtract(%SoftBank.Note{} = m, subtractend) when is_float(subtractend), do: subtract(m, round(subtractend * 100)) def subtract(a, b), do: fail_currencies_must_be_equal(a, b) @spec multiply(t, integer | float) :: t @doc ~S""" Multiplies a `SoftBank.Note` by an amount ## Example: iex> SoftBank.Note.multiply(SoftBank.Note.new(100, :USD), 10) %SoftBank.Note{amount: 1000, currency: :USD} iex> SoftBank.Note.multiply(SoftBank.Note.new(100, :USD), 1.5) %SoftBank.Note{amount: 150, currency: :USD} """ def multiply(%SoftBank.Note{amount: amount, currency: cur}, multiplier) when is_integer(multiplier), do: SoftBank.Note.new(amount * multiplier, cur) def multiply(%SoftBank.Note{amount: amount, currency: cur}, multiplier) when is_float(multiplier), do: SoftBank.Note.new(round(amount * multiplier), cur) @spec divide(t, integer) :: [t] @doc ~S""" Divides up `SoftBank.Note` by an amount ## Example: iex> SoftBank.Note.divide(SoftBank.Note.new(100, :USD), 2) [%SoftBank.Note{amount: 50, currency: :USD}, %SoftBank.Note{amount: 50, currency: :USD}] iex> SoftBank.Note.divide(SoftBank.Note.new(101, :USD), 2) [%SoftBank.Note{amount: 51, currency: :USD}, %SoftBank.Note{amount: 50, currency: :USD}] """ def divide(%SoftBank.Note{amount: amount, currency: cur}, denominator) when is_integer(denominator) do value = div(amount, denominator) rem = rem(amount, denominator) do_divide(cur, value, rem, denominator, []) end defp do_divide(_currency, _value, _rem, 0, acc), do: acc |> Enum.reverse() defp do_divide(currency, value, 0, count, acc) do count = decrement_abs(count) acc = [new(value, currency) | acc] do_divide(currency, value, 0, count, acc) end defp do_divide(currency, value, rem, count, acc) do rem = decrement_abs(rem) count = decrement_abs(count) acc = [new(increment_abs(value), currency) | acc] do_divide(currency, value, rem, count, acc) end defp increment_abs(n) when n >= 0, do: n + 1 defp increment_abs(n) when n < 0, do: n - 1 defp decrement_abs(n) when n >= 0, do: n - 1 defp decrement_abs(n) when n < 0, do: n + 1 @spec to_string(t, Keyword.t()) :: String.t() @doc ~S""" Converts a `SoftBank.Note` struct to a string representation The following options are available: - `separator` - default `","`, sets the separator for groups of thousands. "1,000" - `delimeter` - default `"."`, sets the decimal delimeter. "1.23" - `symbol` - default `true`, sets whether to display the currency symbol or not. - `symbol_on_right` - default `false`, display the currency symbol on the right of the number, eg: 123.45€ - `symbol_space` - default `false`, add a space between currency symbol and number, eg: € 123,45 or 123.45 € - `fractional_unit` - default `true`, show the remaining units after the delimeter ## Example: iex> SoftBank.Note.to_string(SoftBank.Note.new(123456, :GBP)) "£1,234.56" iex> SoftBank.Note.to_string(SoftBank.Note.new(123456, :EUR), separator: ".", delimeter: ",") "€1.234,56" iex> SoftBank.Note.to_string(SoftBank.Note.new(123456, :EUR), symbol: false) "1,234.56" iex> SoftBank.Note.to_string(SoftBank.Note.new(123456, :EUR), symbol: false, separator: "") "1234.56" iex> SoftBank.Note.to_string(SoftBank.Note.new(123456, :EUR), fractional_unit: false) "€1,234" It can also be interpolated (It implements the String.Chars protocol) To control the formatting, you can use the above options in your config, more information is in the introduction to `SoftBank.Note` ## Example: iex> "Total: #{SoftBank.Note.new(100_00, :USD)}" "Total: $100.00" """ def to_string(%SoftBank.Note{} = note, opts \\ []) do {separator, delimeter, symbol, symbol_on_right, symbol_space, fractional_unit} = get_display_options(note, opts) number = format_number(note, separator, delimeter, fractional_unit) sign = if negative?(note), do: "-" space = if symbol_space, do: " " parts = if symbol_on_right do [sign, number, space, symbol] else [symbol, space, sign, number] end parts |> Enum.join() |> String.lstrip() end defp format_number(%SoftBank.Note{amount: amount}, separator, delimeter, fractional_unit) do super_unit = div(Kernel.abs(amount), 100) |> Integer.to_string() |> reverse_group(3) |> Enum.join(separator) sub_unit = rem(Kernel.abs(amount), 100) |> Integer.to_string() |> String.rjust(2, ?0) if fractional_unit do [super_unit, sub_unit] |> Enum.join(delimeter) else super_unit end end defp get_display_options(m, opts) do {separator, delimeter} = get_parse_options(opts) default_symbol = Application.get_env(:note, :symbol, true) default_symbol_on_right = Application.get_env(:note, :symbol_on_right, false) default_symbol_space = Application.get_env(:note, :symbol_space, false) default_fractional_unit = Application.get_env(:note, :fractional_unit, true) symbol = if Keyword.get(opts, :symbol, default_symbol), do: Currency.symbol(m), else: "" symbol_on_right = Keyword.get(opts, :symbol_on_right, default_symbol_on_right) symbol_space = Keyword.get(opts, :symbol_space, default_symbol_space) fractional_unit = Keyword.get(opts, :fractional_unit, default_fractional_unit) {separator, delimeter, symbol, symbol_on_right, symbol_space, fractional_unit} end defp get_parse_options(opts) do default_separator = Application.get_env(:note, :separator, ",") separator = Keyword.get(opts, :separator, default_separator) default_delimeter = Application.get_env(:note, :delimeter, ".") delimeter = Keyword.get(opts, :delimeter, default_delimeter) {separator, delimeter} end defp fail_currencies_must_be_equal(a, b) do raise ArgumentError, message: "Currency of #{a.currency} must be the same as #{b.currency}" end defp reverse_group(str, count) when is_binary(str) do reverse_group(str, Kernel.abs(count), []) end defp reverse_group("", _count, list) do list end defp reverse_group(str, count, list) do {first, last} = String.split_at(str, -count) reverse_group(first, count, [last | list]) end defimpl String.Chars do def to_string(%SoftBank.Note{} = m) do SoftBank.Note.to_string(m) end end end