diff --git a/lib/gringotts.ex b/lib/gringotts.ex index 0bd368e9..13d0eb07 100644 --- a/lib/gringotts.ex +++ b/lib/gringotts.ex @@ -27,24 +27,45 @@ defmodule Gringotts do This argument represents the "amount", annotated with the currency unit for the transaction. `amount` is polymorphic thanks to the `Gringotts.Money` - protocol which can be implemented by your custom Money type. + protocol which can even be implemented by your very own custom Money type! #### Note - We support [`ex_money`][ex_money] and [`monetized`][monetized] out of the - box, and you can drop their types in this argument and everything will work - as expected. - Otherwise, just wrap your `amount` with the `currency` together in a `Map` like so, - money = %{amount: Decimal.new(100.50), currency: "USD"} + Gringotts supports [`ex_money`][ex_money] out of the box, just drop `ex_money` + types in this argument and everything will work as expected. + + > Support for [`monetized`][monetized] and [`money`][money] is on the + > way, track it [here][iss-money-lib-support]! + Otherwise, just wrap your `amount` with the `currency` together in a `Map` like so, + money = %{value: Decimal.new("100.50"), currency: "USD"} + + > When this highly precise `amount` is serialized into the network request, we + > use a potentially lossy `Gringotts.Money.to_string/1` or + > `Gringotts.Money.to_integer/1` to perform rounding (if required) using the + > [`half-even`][wiki-half-even] strategy. + > + > **Hence, to ensure transparency, protect sanity and save _real_ money, we + > STRONGLY RECOMMEND that merchants perform any required rounding and handle + > remainders in their application logic -- before passing the `amount` to + > Gringotts's API.** + #### Example If you use `ex_money` in your project, and want to make an authorization for - $2.99 to MONEI, you'll do the following: + $2.99 to the `XYZ` Gateway, you'll do the following: + + # the money lib is aliased as "MoneyLib" - amount = Money.new(2.99, :USD) - Gringotts.authorize(Gringotts.Gateways.Monei, amount, some_card, extra_options) + amount = MoneyLib.new("2.99", :USD) + Gringotts.authorize(Gringotts.Gateways.XYZ, amount, some_card, extra_options) + [ex_money]: https://hexdocs.pm/ex_money/readme.html + [monetized]: https://hexdocs.pm/monetized/ + [money]: https://hexdocs.pm/money/Money.html + [iss-money-lib-support]: https://github.com/aviabird/gringotts/projects/3#card-6801146 + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + ### `card`, a payment source Gringotts provides a `Gringotts.CreditCard` type to hold card parameters @@ -52,6 +73,7 @@ defmodule Gringotts do card details. #### Note + Gringotts only supports payment by debit or credit card, even though the gateways might support payment via other instruments such as e-wallets, vouchers, bitcoins or banks. Support for these instruments is planned in @@ -71,10 +93,7 @@ defmodule Gringotts do `opts` is a `keyword` list of other options/information accepted by the gateway. The format, use and structure is gateway specific and documented in the Gateway's docs. - - [ex_money]: https://hexdocs.pm/ex_money/readme.html - [monetized]: https://hexdocs.pm/monetized/ - + ## Configuration Merchants must provide Gateway specific configuration in their application @@ -140,9 +159,9 @@ defmodule Gringotts do To (pre) authorize a payment of $4.20 on a sample `card` with the `XYZ` gateway, - amount = Money.new(4.2, :USD) - # IF YOU DON'T USE ex_money OR monetized - # amount = %{value: Decimal.new(4.2), currency: "EUR"} + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.XYZ, amount, card, opts) """ @@ -163,9 +182,9 @@ defmodule Gringotts do To capture $4.20 on a previously authorized payment worth $4.20 by referencing the obtained authorization `id` with the `XYZ` gateway, - amount = Money.new(4.2, :USD) - # IF YOU DON'T USE ex_money OR monetized - # amount = %{value: Decimal.new(4.2), currency: "EUR"} + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} Gringotts.capture(Gringotts.Gateways.XYZ, amount, auth_result.id, opts) """ @@ -191,9 +210,9 @@ defmodule Gringotts do To process a purchase worth $4.2, with the `XYZ` gateway, - amount = Money.new(4.2, :USD) - # IF YOU DON'T USE ex_money OR monetized - # amount = %{value: Decimal.new(4.2), currency: "EUR"} + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} Gringotts.purchase(Gringotts.Gateways.XYZ, amount, card, opts) """ @@ -212,9 +231,9 @@ defmodule Gringotts do To refund a previous purchase worth $4.20 referenced by `id`, with the `XYZ` gateway, - amount = Money.new(4.2, :USD) - # IF YOU DON'T USE ex_money OR monetized - # amount = %{value: Decimal.new(4.2), currency: "EUR"} + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} Gringotts.purchase(Gringotts.Gateways.XYZ, amount, id, opts) """ def refund(gateway, amount, id, opts \\ []) do @@ -282,9 +301,6 @@ defmodule Gringotts do call(:payment_worker, {:void, gateway, id, opts}) end - - # TODO: This is runtime error reporting fix this so that it does compile - # time error reporting. defp validate_config(gateway) do # Keep the key name and adapter the same in the config in application config = Application.get_env(:gringotts, gateway) diff --git a/lib/gringotts/gateways/monei.ex b/lib/gringotts/gateways/monei.ex index 4087bf0c..bebf8eb1 100644 --- a/lib/gringotts/gateways/monei.ex +++ b/lib/gringotts/gateways/monei.ex @@ -178,17 +178,19 @@ defmodule Gringotts.Gateways.Monei do The following session shows how one would (pre) authorize a payment of $40 on a sample `card`. iex> amount = %{value: Decimal.new(42), currency: "EUR"} - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> auth_result = Gringotts.authorize(Gringotts.Gateways.Monei, amount, card, opts) iex> auth_result.id # This is the authorization ID """ - @spec authorize(Money.t, CreditCard.t(), keyword) :: {:ok | :error, Response} + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} def authorize(amount, card = %CreditCard{}, opts) do + {currency, value} = Money.to_string(amount) + params = [ paymentType: "PA", - amount: amount |> Money.value |> Decimal.to_float |> :erlang.float_to_binary(decimals: 2), - currency: Money.currency(amount) + amount: value, + currency: currency ] ++ card_params(card) auth_info = Keyword.fetch!(opts, :config) @@ -215,17 +217,19 @@ defmodule Gringotts.Gateways.Monei do authorized a payment worth $35 by referencing the obtained authorization `id`. iex> amount = %{value: Decimal.new(42), currency: "EUR"} - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> capture_result = Gringotts.capture(Gringotts.Gateways.Monei, 35, auth_result.id, opts) """ - @spec capture(Money.t, String.t(), keyword) :: {:ok | :error, Response} + @spec capture(Money.t(), String.t(), keyword) :: {:ok | :error, Response} def capture(amount, payment_id, opts) def capture(amount, <>, opts) do + {currency, value} = Money.to_string(amount) + params = [ paymentType: "CP", - amount: amount |> Money.value |> Decimal.to_float |> :erlang.float_to_binary(decimals: 2), - currency: Money.currency(amount) + amount: value, + currency: currency ] auth_info = Keyword.fetch!(opts, :config) @@ -243,18 +247,20 @@ defmodule Gringotts.Gateways.Monei do The following session shows how one would process a payment in one-shot, without (pre) authorization. - iex> amount = %{value: Decimal.new(42), currency: "EUR"} - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> amount = %{value: Decimal.new(42), currency: "EUR"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> purchase_result = Gringotts.purchase(Gringotts.Gateways.Monei, amount, card, opts) """ - @spec purchase(Money.t, CreditCard.t(), keyword) :: {:ok | :error, Response} + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} def purchase(amount, card = %CreditCard{}, opts) do + {currency, value} = Money.to_string(amount) + params = card_params(card) ++ [ paymentType: "DB", - amount: amount |> Money.value |> Decimal.to_float |> :erlang.float_to_binary(decimals: 2), - currency: Money.currency(amount) + amount: value, + currency: currency ] auth_info = Keyword.fetch!(opts, :config) @@ -290,7 +296,7 @@ defmodule Gringotts.Gateways.Monei do authorization. Remember that our `capture/3` example only did a partial capture. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> void_result = Gringotts.void(Gringotts.Gateways.Monei, auth_result.id, opts) """ @spec void(String.t(), keyword) :: {:ok | :error, Response} @@ -319,15 +325,17 @@ defmodule Gringotts.Gateways.Monei do similarily for captures). iex> amount = %{value: Decimal.new(42), currency: "EUR"} - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> refund_result = Gringotts.refund(Gringotts.Gateways.Monei, purchase_result.id, amount) """ - @spec refund(Money.t, String.t(), keyword) :: {:ok | :error, Response} + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response} def refund(amount, <>, opts) do + {currency, value} = Money.to_string(amount) + params = [ paymentType: "RF", - amount: amount |> Money.value |> Decimal.to_float |> :erlang.float_to_binary(decimals: 2), - currency: Money.currency(amount) + amount: value, + currency: currency ] auth_info = Keyword.fetch!(opts, :config) @@ -354,7 +362,7 @@ defmodule Gringotts.Gateways.Monei do The following session shows how one would store a card (a payment-source) for future use. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} iex> store_result = Gringotts.store(Gringotts.Gateways.Monei, card, []) """ @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response} diff --git a/lib/gringotts/money.ex b/lib/gringotts/money.ex index 431ce442..304cc764 100644 --- a/lib/gringotts/money.ex +++ b/lib/gringotts/money.ex @@ -8,38 +8,148 @@ defprotocol Gringotts.Money do If your application is already using a supported Money library, just pass in the Money struct and things will work out of the box. - Otherwise, just wrap your `amount` with the `currency` together in a `Map` like so, - money = %{amount: Decimal.new(2017.18), currency: "USD"} + Otherwise, just wrap your `amount` with the `currency` together in a `Map` + like so, - and the API will accept it (as long as the currency is valid [ISO 4217 currency - code](https://www.iso.org/iso-4217-currency-codes.html)). + price = %{value: Decimal.new("20.18"), currency: "USD"} + + and the API will accept it (as long as the currency is valid [ISO 4217 + currency code](https://www.iso.org/iso-4217-currency-codes.html)). + + ## Note on the `Any` implementation + + Both `to_string/1` and `to_integer/1` assume that the precision for the `currency` + is 2 digits after decimal. """ @fallback_to_any true - @type t :: Gringotts.Money.t - - @spec currency(t) :: String.t + @type t :: Gringotts.Money.t() + @doc """ Returns the ISO 4217 compliant currency code associated with this sum of money. This must be an UPCASE `string` """ + @spec currency(t) :: String.t() def currency(money) - @spec value(t) :: Decimal.t @doc """ - Returns a Decimal representing the "worth" of this sum of money in the + Returns a `Decimal.t` representing the "worth" of this sum of money in the associated `currency`. """ + @spec value(t) :: Decimal.t() def value(money) + + @doc """ + Returns the ISO4217 `currency` code as string and `value` as an integer. + + Useful for gateways that require amount as integer (like cents instead of + dollars). + + ## Note + + Conversion from `Decimal.t` to `integer` is potentially lossy and the rounding + (if required) is performed (automatically) by the Money library defining the + type, or in the implementation of this protocol method. + + If you want to implement this method for your custom type, please ensure that + the rounding strategy (if any rounding is applied) must be + [`half_even`][wiki-half-even]. + + **To keep things predictable and transparent, merchants should round the + `amount` themselves**, perhaps by explicitly calling the relevant method of + the Money library in their application _before_ passing it to `Gringotts`'s + public API. + + ## Examples + + # the money lib is aliased as "MoneyLib" + + iex> usd_price = MoneyLib.new("4.1234", :USD) + #MoneyLib<4.1234, "USD"> + iex> Gringotts.Money.to_integer(usd_price) + {"USD", 412, -2} + + iex> bhd_price = MoneyLib.new("4.1234", :BHD) + #MoneyLib<4.1234, "BHD"> + iex> Gringotts.Money.to_integer(bhd_price) + {"BHD", 4123, -3} + # the Bahraini dinar is divided into 1000 fils unlike the dollar which is + # divided in 100 cents + + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + """ + @spec to_integer(Money.t()) :: + {currency :: String.t(), value :: integer, exponent :: neg_integer} + def to_integer(money) + + @doc """ + Returns a tuple of ISO4217 `currency` code and the `value` as strings. + + The stringified `value` must match this regex: `~r/-?\\d+\\.\\d\\d{n}/` where + `n+1` should match the required precision for the `currency`. There should be + no place value separators except the decimal point (like commas). + + > Gringotts will not (and cannot) validate this of course. + + ## Note + + Conversion from `Decimal.t` to `string` is potentially lossy and the rounding + (if required) is performed (automatically) by the Money library defining the + type, or in the implementation of this protocol method. + + If you want to implement this method for your custom type, please ensure that + the rounding strategy (if any rounding is applied) must be + [`half_even`][wiki-half-even]. + + **To keep things predictable and transparent, merchants should round the + `amount` themselves**, perhaps by explicitly calling the relevant method of + the Money library in their application _before_ passing it to `Gringotts`'s + public API. + + ## Examples + + # the money lib is aliased as "MoneyLib" + + iex> usd_price = MoneyLib.new("4.1234", :USD) + #MoneyLib<4.1234, "USD"> + iex> Gringotts.Money.to_string(usd_price) + {"USD", "4.12"} + + iex> bhd_price = MoneyLib.new("4.1234", :BHD) + #MoneyLib<4.1234, "BHD"> + iex> Gringotts.Money.to_string(bhd_price) + {"BHD", "4.123"} + + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + """ + @spec to_string(t) :: {currency :: String.t(), value :: String.t()} + def to_string(money) end # this implementation is used for dispatch on ex_money (and will also fire for # money) if Code.ensure_compiled?(Money) do defimpl Gringotts.Money, for: Money do - def currency(money), do: money.currency |> Atom.to_string + def currency(money), do: money.currency |> Atom.to_string() def value(money), do: money.amount - end + + def to_integer(money) do + {_, int_value, exponent, _} = Money.to_integer_exp(money) + {currency(money), int_value, exponent} + end + + def to_string(money) do + {:ok, currency_data} = Cldr.Currency.currency_for_code(currency(money)) + reduced = Money.reduce(money) + + { + currency(reduced), + value(reduced) + |> Decimal.round(currency_data.digits) + |> Decimal.to_string() + } + end + end end if Code.ensure_compiled?(Monetized.Money) do @@ -49,7 +159,26 @@ if Code.ensure_compiled?(Monetized.Money) do end end +# Assumes that the currency is subdivided into 100 units defimpl Gringotts.Money, for: Any do - def currency(money), do: Map.get(money, :currency) - def value(money), do: Map.get(money, :amount) || Map.get(money, :value) + def currency(%{currency: currency}), do: currency + def value(%{value: %Decimal{} = value}), do: value + + def to_integer(%{value: %Decimal{} = value, currency: currency}) do + { + currency, + value + |> Decimal.mult(Decimal.new(100)) + |> Decimal.round(0) + |> Decimal.to_integer(), + -2 + } + end + + def to_string(%{value: %Decimal{} = value, currency: currency}) do + { + currency, + value |> Decimal.round(2) |> Decimal.to_string() + } + end end diff --git a/test/integration/money.exs b/test/integration/money.exs new file mode 100644 index 00000000..ca42febe --- /dev/null +++ b/test/integration/money.exs @@ -0,0 +1,61 @@ +defmodule Gringotts.Integration.Gateways.MoneyTest do + use ExUnit.Case, async: true + + alias Gringotts.Money, as: MoneyProtocol + + @moduletag :integration + + @ex_money Money.new(42, :EUR) + @ex_money_long Money.new("42.126456", :EUR) + @ex_money_bhd Money.new(42, :BHD) + + @any %{value: Decimal.new(42), currency: "EUR"} + @any_long %{value: Decimal.new("42.126456"), currency: "EUR"} + @any_bhd %{value: Decimal.new("42"), currency: "BHD"} + + describe "ex_money" do + test "value is a Decimal.t" do + assert match? %Decimal{}, MoneyProtocol.value(@ex_money) + end + + test "currency is an upcase String.t" do + the_currency = MoneyProtocol.currency(@ex_money) + assert match? currency when is_binary(currency), the_currency + assert the_currency == String.upcase(the_currency) + end + + test "to_integer" do + assert match? {"EUR", 4200, -2}, MoneyProtocol.to_integer(@ex_money) + assert match? {"BHD", 42000, -3}, MoneyProtocol.to_integer(@ex_money_bhd) + end + + test "to_string" do + assert match? {"EUR", "42.00"}, MoneyProtocol.to_string(@ex_money) + assert match? {"EUR", "42.13"}, MoneyProtocol.to_string(@ex_money_long) + assert match? {"BHD", "42.000"}, MoneyProtocol.to_string(@ex_money_bhd) + end + end + + describe "Any" do + test "value is a Decimal.t" do + assert match? %Decimal{}, MoneyProtocol.value(@any) + end + + test "currency is an upcase String.t" do + the_currency = MoneyProtocol.currency(@any) + assert match? currency when is_binary(currency), the_currency + assert the_currency == String.upcase(the_currency) + end + + test "to_integer" do + assert match? {"EUR", 4200, -2}, MoneyProtocol.to_integer(@any) + assert match? {"BHD", 4200, -2}, MoneyProtocol.to_integer(@any_bhd) + end + + test "to_string" do + assert match? {"EUR", "42.00"}, MoneyProtocol.to_string(@any) + assert match? {"EUR", "42.13"}, MoneyProtocol.to_string(@any_long) + assert match? {"BHD", "42.00"}, MoneyProtocol.to_string(@any_bhd) + end + end +end