diff --git a/lib/gringotts/gateways/pin_payments.ex b/lib/gringotts/gateways/pin_payments.ex new file mode 100644 index 00000000..211cb54c --- /dev/null +++ b/lib/gringotts/gateways/pin_payments.ex @@ -0,0 +1,369 @@ +defmodule Gringotts.Gateways.PinPayments do + @moduledoc """ + [PinPayments][home] gateway implementation. + + The following features of PinPayments are implemented: + + | Action | Method | + | ------ | ------ | + | Authorize | `authorize/3` | + | Capture | `capture/3` | + | Purchase | `purchase/3` | + | Store | `store/2` | + | Refund | `refund/3` | + + ## The `opts` argument + + Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply + optional arguments for transactions with the PinPayments gateway. The following keys + are supported: + + | Key | Type | Remark | + | ---- | ---- | --- | + | `address` | `Address.t`| The address of the customer | + | `email_id` | `String.t` | The email address of the purchaser. | + | `description` | `String.t` | A description of the item purchased (e.g. 500g of single origin beans) | + | `ip_address` | `String.t` | The IP address of the person submitting the payment(optional) | + + > PinPayments supports more optional keys and you can raise [issues] if + this is important to you. + + [issues]: https://github.com/aviabird/gringotts/issues/new + + ## Registering your PinPayments account at `Gringotts` + + | Config parameter | PinPayments secret | + | ------- | ---- | + | `:api_key` | `**API_SECRET_KEY**` | + + > Your Application config **must include the `:api_key` + > fields** and would look something like this: + + config :gringotts, Gringotts.Gateways.Pinpay, + api_key: "your_secret_key", + + + * PinPayments **does** process money in cents. + * Although PinPayments supports payments various cards. This module only + accepts payments via `VISA`, `MASTER`, and `AMERICAN EXPRESS`. + + ## Supported countries + PinPayments supports the countries listed + * Australia + * New Zealand + + ## Supported currencies + PinPayments supports the currencies listed [here](https://pinPayments.com/developers/api-reference/currency-support) + + ## Following the examples + + 1. First, set up a sample application and configure it to work with PinPayments. + - You could do that from scratch by following our [Getting Started][gs] guide. + - To save you time, we recommend [cloning our example + repo][example] that gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" + as described [above](#module-registering-your-pinpayments-account-at-gringotts). + + 2. Run an `iex` session with `iex -S mix` and add some variable bindings and + aliases to it (to save some time): + ``` + iex> alias Gringotts.{Response, CreditCard, Gateways.Pinpay} + iex> card = %CreditCard{first_name: "Jo", + last_name: "Doe", + number: "4200000000000000", + year: 2099, month: 12, + verification_code: "123", brand: "VISA"} + ``` + + We'll be using these in the examples below. + + [gs]: https://github.com/aviabird/gringotts/wiki/ + [home]: https://pinPayments.com + [docs]: https://pinPayments.com/developers/api-reference + [example]: https://github.com/aviabird/gringotts_example + """ + + # The Base module has the (abstract) public API, and some utility + # implementations. + use Gringotts.Gateways.Base + + # The Adapter module provides the `validate_config/1` + # Add the keys that must be present in the Application config in the + # `required_config` list + use Gringotts.Adapter, required_config: [:api_key] + + import Poison, only: [decode: 1] + + alias Gringotts.{Money, CreditCard, Response} + + @test_url "https://test-api.pinPayments.com/1/" + + @doc """ + Creates a new charge and returns its details. + + The authorization validates the `card` details with the banking network, + places a hold on the transaction `amount` in the customer’s issuing bank. + + PinPayments returns a **Token Id** which can be used later to: + * `capture/3` an amount. + + ## Example + + The following example shows how one would (pre) authorize a payment of $20 on + a sample `card`. + ``` + iex> card = %CreditCard{first_name: "Jo", + last_name: "Doe", + number: "4200000000000000", + year: 2099, month: 12, + verification_code: "123", brand: "VISA"} + iex> money = Money.new(20, :USD) + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.PinPayments, amount, card, opts) + ``` + """ + + @spec authorize(Money.t(), CreditCard.t() | String.t(), keyword) :: {:ok | :error, Response} + def authorize(amount, %CreditCard{} = card, opts) do + {currency, value, _} = Money.to_integer(amount) + + with {:ok, card_token_response} <- + commit(:post, "cards", card_for_token(card, opts) ++ Keyword.delete(opts, :address)), + {:ok, card_token} <- extract_card_token(card_token_response) do + params = + [ + amount: value, + capture: false, + card_token: card_token, + currency: currency + ] ++ Keyword.delete(opts, :address) + + commit(:post, "charges", params) + end + end + + def authorize(amount, card_token, opts) when is_binary(card_token) do + {currency, value, _} = Money.to_integer(amount) + + params = + [ + amount: value, + capture: false, + currency: currency, + card_token: card_token + ] ++ Keyword.delete(opts, :address) + + commit(:post, "charges", params) + end + + @doc """ + Transfers `amount` from the customer to the merchant. + + PinPayments attempts to process a purchase on behalf of the customer, by + debiting `amount` from the customer's account by charging the customer's + `card`. + + PinPayments returns a **Payment Id** which can be used later to: + * `refund/3` the amount. + + ## Examples + + The following example shows how one would process a payment worth $20 in + one-shot, without (pre) authorization. + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4200000000000000", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> money = Money.new(20, :USD) + iex> {:ok, purchase_result} = Gringotts.purchase(Gringotts.Gateways.PinPayments, amount, card, opts) + ``` + """ + + @spec purchase(Money.t(), CreditCard.t() | String.t(), keyword) :: {:ok | :error, Response} + def purchase(amount, %CreditCard{} = card, opts) do + {currency, value, _} = Money.to_integer(amount) + + with {:ok, card_token_response} <- + commit(:post, "cards", card_for_token(card, opts) ++ Keyword.delete(opts, :address)), + {:ok, card_token} <- extract_card_token(card_token_response) do + params = + [ + amount: value, + card_token: card_token, + currency: currency + ] ++ Keyword.delete(opts, :address) + + commit(:post, "charges", params) + end + end + + def purchase(amount, card_token, opts) when is_binary(card_token) do + {currency, value, _} = Money.to_integer(amount) + + params = + [ + amount: value, + card_token: card_token, + currency: currency + ] ++ Keyword.delete(opts, :address) + + commit(:post, "charges", params) + end + + @doc """ + Refunds the `amount` to the customer's account with reference to a prior transfer. + + PinPayments processes a full refund worth `amount`, referencing a + previous `purchase/3` or `capture/3`. + + The end customer will usually see two bookings/records on his statement. + + ## Example + ``` + iex> money = Money.new(20, :USD) + iex> {:ok, refund_result} = Gringotts.refund(Gringotts.Gateways.PinPayments, payment_id, opts) + ``` + """ + @spec refund(String.t(), keyword) :: {:ok | :error, Response} + def refund(payment_id, opts) do + url = @test_url <> "charges/#{payment_id}/refunds" + + commit_short(:post, url, opts) + end + + @doc """ + Stores the payment-source data for later use. + + PinPayments can store the payment-source details, for example card or bank details + which can be used to effectively process _One-Click_ and _Recurring_ payments, + and return a card token for reference. + + ## Note + + * _One-Click_ and _Recurring_ payments are currently not implemented. + * Payment details can be saved during a `purchase/3` or `capture/3`. + + ## Example + + The following example shows how one would store a card (a payment-source) for + future use. + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4200000000000000", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> {:ok, store_result} = Gringotts.store(Gringotts.Gateways.PinPayments, card, opts) + ``` + """ + + @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response} + def store(%CreditCard{} = card, opts) do + commit(:post, "cards", card_for_token(card, opts) ++ Keyword.delete(opts, :address)) + end + + ############################################################################### + # PRIVATE METHODS # + ############################################################################### + + # Makes the request to PinPay's network. + # For consistency with other gateway implementations, make your (final) + # network request in here, and parse it using another private method called + # `respond`. + + defp card_for_token(card, opts) do + [ + number: card.number, + name: CreditCard.full_name(card), + expiry_month: card.month |> Integer.to_string() |> String.pad_leading(2, "0"), + expiry_year: card.year |> Integer.to_string(), + cvc: card.verification_code, + address_line1: opts[:address].street1, + address_city: opts[:address].city, + address_country: opts[:address].country, + address_line2: opts[:address].street2, + address_postcode: opts[:address].postal_code, + address_state: opts[:address].region + ] + end + + defp commit_short(method, url, opts) do + auth_token = encoded_credentials(opts[:config][:api_key]) + + headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", auth_token} + ] + + HTTPoison.request(method, url, [], headers) + |> respond + end + + @spec commit(atom, String.t(), keyword) :: {:ok | :error, Response} + defp commit(:post, endpoint, param) do + auth_token = encoded_credentials(param[:config][:api_key]) + + headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", auth_token} + ] + + url = @test_url <> "#{endpoint}" + param = Keyword.delete(param, :config) + + url + |> HTTPoison.post({:form, param}, headers) + |> respond + end + + defp encoded_credentials(login) do + hash = Base.encode64("#{login}:") + "Basic #{hash}" + end + + defp extract_card_token(%{id: token}) do + {:ok, token} + end + + defp extract_card_token({:error, error_response}) do + {:error, error_response} + end + + # Parses PinPay's response and returns a `Gringotts.Response` struct + # in a `:ok`, `:error` tuple. + @spec respond(term) :: {:ok | :error, Response} + + defp respond({:ok, %{status_code: code, body: body}}) when code in 200..299 do + {:ok, parsed} = decode(body) + + token = parsed["response"]["card"]["token"] + id = parsed["response"]["token"] + message = parsed["response"]["status_message"] + + { + :ok, + Response.success( + id: id, + token: token, + message: message, + raw: parsed, + status_code: code + ) + } + end + + defp respond({:ok, %{status_code: status_code, body: body}}) do + {:ok, parsed} = decode(body) + detail = parsed["detail"] + {:error, Response.error(status_code: status_code, message: detail, raw: parsed)} + end + + defp respond({:error, %HTTPoison.Error{} = error}) do + {:error, Response.error(code: error.id, message: "HTTPoison says '#{error.reason}'")} + end +end diff --git a/mix.exs b/mix.exs index 244a6405..c3074816 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule Gringotts.Mixfile do {:mock, "~> 0.3.0", only: :test}, {:bypass, "~> 0.8", only: :test}, {:excoveralls, "~> 0.8", only: :test}, + {:exvcr, "~> 0.10", only: :test}, # various analyses tools {:credo, "~> 0.3", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index aaaa5cc1..83a82360 100644 --- a/mix.lock +++ b/mix.lock @@ -16,8 +16,10 @@ "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "1.3.1", "50a117654dff8f8ee6958e68a65d0c2835a7e2f1aff94c1ea8f582c04fdf0bd4", [:mix], [{:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 1.4.0", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.1 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, "ex_money": {:hex, :ex_money, "1.1.3", "843eed0a5673206de33be47cdc06574401abc3e2d33cbcf6d74e160226791ae4", [:mix], [{:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:ex_cldr, "~> 1.0", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 1.0", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}], "hexpm"}, + "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"}, "excoveralls": {:hex, :excoveralls, "0.8.1", "0bbf67f22c7dbf7503981d21a5eef5db8bbc3cb86e70d3798e8c802c74fa5e27", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, + "exvcr": {:hex, :exvcr, "0.10.0", "5150808404d9f48dbda636f70f7f8fefd93e2433cd39f695f810e73b3a9d1736", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 0.13", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.0", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, "hackney": {:hex, :hackney, "1.11.0", "4951ee019df102492dabba66a09e305f61919a8a183a7860236c0fde586134b6", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/test/integration/gateways/pin_payments_test.exs b/test/integration/gateways/pin_payments_test.exs new file mode 100644 index 00000000..96b4d7e3 --- /dev/null +++ b/test/integration/gateways/pin_payments_test.exs @@ -0,0 +1,133 @@ +defmodule Gringotts.Integration.Gateways.PinPaymentsTest do + # Integration tests for the PinPayments + + use ExUnit.Case, async: false + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + + alias Gringotts.{ + CreditCard, + Address + } + + alias Gringotts.Gateways.PinPayments, as: Gateway + + # @moduletag :integration + + @amount Money.new(4, :AUD) + + @bad_card1 %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "4100000000000001", + year: 2019, + month: 12, + verification_code: "123", + brand: "VISA" + } + + @bad_card2 %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "4600000000000006", + year: 2019, + month: 12, + verification_code: "123", + brand: "VISA" + } + + @bad_card3 %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "4600000000000006", + year: 2009, + month: 12, + verification_code: "123", + brand: "VISA" + } + + @good_card %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "4200000000000000", + year: 2019, + month: 12, + verification_code: "123", + brand: "VISA" + } + + @add %Address{ + street1: "OBH", + street2: "AIT", + city: "PUNE", + region: "Maharashtra", + country: "IN", + postal_code: "411015", + phone: "8007810916" + } + + @opts [ + description: "hello", + email: "hi@hello.com", + ip_address: "1.1.1.1", + config: [api_key: "c4nxgznanW4XZUaEQhxS6g"] + ] ++ [address: @add] + + # Group the test cases by public api + describe "purchase" do + test "[purchase] with CreditCard" do + use_cassette "pin_pay/purchase_with_credit_card" do + assert {:ok, response} = Gateway.purchase(@amount, @good_card, @opts) + assert response.success == true + assert response.status_code == 201 + end + end + + test "[purchase] with bad CreditCard 1" do + use_cassette "pin_pay/purchase_with_bad_credit_card1" do + assert {:error, response} = Gateway.purchase(@amount, @bad_card1, @opts) + assert response.success == false + assert response.status_code == 400 + end + end + + test "[purchase] with bad CreditCard 2" do + use_cassette "pin_pay/purchase_with_bad_credit_card2" do + assert {:error, response} = Gateway.purchase(@amount, @bad_card2, @opts) + assert response.success == false + assert response.status_code == 400 + end + end + + test "[purchase] with bad CreditCard 3" do + use_cassette "pin_pay/purchase_with_bad_credit_card3" do + assert {:error, response} = Gateway.purchase(@amount, @bad_card3, @opts) + assert response.success == false + assert response.status_code == 422 + end + end + + test "[purchase] with card_token" do + use_cassette "pin_pay/purchase_with_card_token" do + assert {:ok, response} = Gateway.store(@good_card, @opts) + assert response.success == true + assert response.status_code == 201 + card_token = response.id + assert {:ok, response} = Gateway.purchase(@amount, card_token, @opts) + end + end + end + + describe "Refunds" do + test "[Refunds]" do + use_cassette "pin_pay/refunds" do + assert {:ok, response} = Gateway.purchase(@amount, @good_card, @opts) + assert response.success == true + assert response.status_code == 201 + payment_id = response.id + assert {:ok, response} = Gateway.refund(payment_id, @opts) + assert response.success == true + assert response.status_code == 201 + end + end + end +end