aviabird/gringotts

View on GitHub
lib/gringotts/gateways/paymill.ex

Summary

Maintainability
Test Coverage
defmodule Gringotts.Gateways.Paymill do
  @moduledoc """
  [PAYMILL][home] gateway implementation.

  For refernce see [PAYMILL's API (v2.1) documentation][docs].

  The following features of PAYMILL are implemented:

  | Action                       | Method        |
  | ------                       | ------        |
  | Authorize                    | `authorize/3` |
  | Capture                      | `capture/3`   |
  | Purchase                     | `purchase/3`  |
  | Refund                       | `refund/3`    |
  | Void                         | `void/2`      |

  ## The `opts` argument

  Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply
  optional arguments for transactions with the PAYMILL gateway. **Currently, no
  optional params are supported.**

  ## Registering your PAYMILL account at `Gringotts`

  After [making an account on PAYMILL][dashboard], head to the dashboard and find
  your account "secrets".

  Here's how the secrets map to the required configuration parameters for PAYMILL:

  | Config parameter | PAYMILL secret  |
  | -------          | ----            |
  | `:private_key`   | **Private Key** |
  | `:public_key`    | **Public Key**  |

  Your Application config **must include the `:private_key`, `:public_key`
  fields** and would look something like this:

      config :gringotts, Gringotts.Gateways.Paymill,
        private_key: "your_secret_private_key",
        public_key: "your_secret_public_key"

  ## Scope of this module

  * PAYMILL processes money in the sub-divided unit of currency (ie, in case of
    USD it works in cents).
  * This module does not offer direct API integration for [PCI DSS][pci-dss]
    compliant merchants. Hence, you can use this module even if your
    infrastructure (servers) are not PCI-DSS compliant!
  * To use their product, a merchant (aka user of this library) would have to
    use their [Bridge (js integration)][bridge] (or equivalent) in your
    application frontend to collect Credit/Debit Card data.
  * This would obtain a unique `card_token` at the client-side which can be used
    by this module for various operations like `authorize/3` and `purchase/3`.

  [bridge]: https://developers.paymill.com/guides/reference/paymill-bridge.html

  ## Supported countries
  As a PAYMILL merchant you can accept payments from around the globe. For more details
  refer to [Paymill country support][country-support].

  ## Supported currencies
  Your transactions will be processed in your native currency. For more information
  refer to [Paymill currency support][currency-support].

  ## Following the examples

  1. First, set up a sample application and configure it to work with PAYMILL.
  - You could do that from scratch by following our [Getting Started][gs] guide.
      - To save you time, we recommend [cloning our example repo][example-repo]
        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-paymill-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.Paymill}
  iex> amount = Money.new(4200, :EUR)
  ```

  We'll be using these in the examples below.

  [home]: https://paymill.com
  [docs]: https://developers.paymill.com
  [dashboard]: https://app.paymill.com/user/register
  [gs]: https://github.com/aviabird/gringotts/wiki
  [example-repo]: https://github.com/aviabird/gringotts_example
  [currency-support]: https://www.paymill.com/en/faq/in-which-currency-will-my-transactions-be-processed-and-payout-in
  [country-support]: https://www.paymill.com/en/faq/which-countries-is-paymill-available-in
  [pci-dss]: https://www.paymill.com/en/pci-dss
  """

  use Gringotts.Gateways.Base
  use Gringotts.Adapter, required_config: [:private_key, :public_key]

  alias Gringotts.{Money, Response}

  @base_url "https://api.paymill.com/v2.1/"
  @headers [{"Content-Type", "application/x-www-form-urlencoded"}]

  @response_code %{
    10_001 => "Undefined response",
    10_002 => "Waiting for something",
    11_000 => "Retry request at a later time",
    20_000 => "Operation successful",
    20_100 => "Funds held by acquirer",
    20_101 => "Funds held by acquirer because merchant is new",
    20_200 => "Transaction reversed",
    20_201 => "Reversed due to chargeback",
    20_202 => "Reversed due to money-back guarantee",
    20_203 => "Reversed due to complaint by buyer",
    20_204 => "Payment has been refunded",
    20_300 => "Reversal has been canceled",
    22_000 => "Initiation of transaction successful",
    30_000 => "Transaction still in progress",
    30_100 => "Transaction has been accepted",
    31_000 => "Transaction pending",
    31_100 => "Pending due to address",
    31_101 => "Pending due to uncleared eCheck",
    31_102 => "Pending due to risk review",
    31_103 => "Pending due regulatory review",
    31_104 => "Pending due to unregistered/unconfirmed receiver",
    31_200 => "Pending due to unverified account",
    31_201 => "Pending due to non-captured funds",
    31_202 => "Pending due to international account (accept manually)",
    31_203 => "Pending due to currency conflict (accept manually)",
    31_204 => "Pending due to fraud filters (accept manually)",
    40_000 => "Problem with transaction data",
    40_001 => "Problem with payment data",
    40_002 => "Invalid checksum",
    40_100 => "Problem with credit card data",
    40_101 => "Problem with CVV",
    40_102 => "Card expired or not yet valid",
    40_103 => "Card limit exceeded",
    40_104 => "Card is not valid",
    40_105 => "Expiry date not valid",
    40_106 => "Credit card brand required",
    40_200 => "Problem with bank account data",
    40_201 => "Bank account data combination mismatch",
    40_202 => "User authentication failed",
    40_300 => "Problem with 3-D Secure data",
    40_301 => "Currency/amount mismatch",
    40_400 => "Problem with input data",
    40_401 => "Amount too low or zero",
    40_402 => "Usage field too long",
    40_403 => "Currency not allowed",
    40_410 => "Problem with shopping cart data",
    40_420 => "Problem with address data",
    40_500 => "Permission error with acquirer API",
    40_510 => "Rate limit reached for acquirer API",
    42_000 => "Initiation of transaction failed",
    42_410 => "Initiation of transaction expired",
    50_000 => "Problem with back end",
    50_001 => "Country blacklisted",
    50_002 => "IP address blacklisted",
    50_004 => "Live mode not allowed",
    50_005 => "Insufficient permissions (API key)",
    50_100 => "Technical error with credit card",
    50_101 => "Error limit exceeded",
    50_102 => "Card declined",
    50_103 => "Manipulation or stolen card",
    50_104 => "Card restricted",
    50_105 => "Invalid configuration data",
    50_200 => "Technical error with bank account",
    50_201 => "Account blacklisted",
    50_300 => "Technical error with 3-D Secure",
    50_400 => "Declined because of risk issues",
    50_401 => "Checksum was wrong",
    50_402 => "Bank account number was invalid (formal check)",
    50_403 => "Technical error with risk check",
    50_404 => "Unknown error with risk check",
    50_405 => "Unknown bank code",
    50_406 => "Open chargeback",
    50_407 => "Historical chargeback",
    50_408 => "Institution / public bank account (NCA)",
    50_409 => "KUNO/Fraud",
    50_410 => "Personal Account Protection (PAP)",
    50_420 => "Rejected due to acquirer fraud settings",
    50_430 => "Rejected due to acquirer risk settings",
    50_440 => "Failed due to restrictions with acquirer account",
    50_450 => "Failed due to restrictions with user account",
    50_500 => "General timeout",
    50_501 => "Timeout on side of the acquirer",
    50_502 => "Risk management transaction timeout",
    50_600 => "Duplicate operation",
    50_700 => "Cancelled by user",
    50_710 => "Failed due to funding source",
    50_711 => "Payment method not usable, use other payment method",
    50_712 => "Limit of funding source was exceeded",
    50_713 => "Means of payment not reusable (canceled by user)",
    50_714 => "Means of payment not reusable (expired)",
    50_720 => "Rejected by acquirer",
    50_730 => "Transaction denied by merchant",
    50_800 => "Preauthorisation failed",
    50_810 => "Authorisation has been voided",
    50_820 => "Authorisation period expired"
  }

  @doc """
  Performs a (pre) Authorize operation.

  The authorization validates the `card` details for `token` with the banking network,
  places a hold on the transaction `amount` in the customer’s issuing bank and
  also triggers risk management. Funds are not transferred.

  The authorization token is available in the `Response.id` field.

  ## Example

  The following example shows how one would (pre) authorize a payment of €42 on
  a sample `token`.
  ```
  iex> amount = Money.new(4200, :EUR)
  iex> card_token = "tok_XXXXXXXXXXXXXXXXXXXXXXXXXXXX"

  iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.Paymill, amount, card_token, opts)
  iex> auth_result.id # This is the preauth-id
  ```
  """

  @spec authorize(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()}
  def authorize(amount, card_token, opts) do
    params = [{:token, card_token} | amount_params(amount)]
    commit(:post, "preauthorizations", params, opts)
  end

  @doc """
  Captures a pre-authorized `amount`.

  `amount` is transferred to the merchant account by PAYMILL when it is smaller or
  equal to the amount used in the pre-authorization referenced by `preauth_id`.

  ## Note

  PAYMILL allows partial captures and unlike many other gateways, and releases
  any remaining amount back to the payment source.
  > Thus, the same pre-authorisation ID **cannot** be used to perform multiple
    captures.

  ## Example

  The following example shows how one would (partially) capture a previously
  authorized a payment worth €42 by referencing the obtained authorization `id`.

  ```
  iex> amount = Money.new(4200, :EUR)
  iex> preauth_id = auth_result.id
  # preauth_id = "some_authorization_id"
  iex> Gringotts.capture(Gringotts.Gateways.Paymill, preauth_id, amount, opts)
  ```
  """
  @spec capture(String.t(), Money.t(), keyword) :: {:ok | :error, Response.t()}
  def capture(id, amount, opts) do
    params = [{:preauthorization, id} | amount_params(amount)]
    commit(:post, "transactions", params, opts)
  end

  @doc """
  Transfers `amount` from the customer to the merchant.

  PAYMILL attempts to process a purchase on behalf of the customer, by debiting
  `amount` from the customer's account by charging the customer's `card` via `token`.

  ## Example

  The following example shows how one would process a payment worth €42 in
  one-shot, without (pre) authorization.

  ```
  iex> amount = Money.new(4200, :EUR)
  iex> token = "tok_XXXXXXXXXXXXXXXXXXXXXXXXXXXX"

  iex> {:ok, purchase_result} = Gringotts.purchase(Gringotts.Gateways.Paymill, amount, token, opts)
  ```
  """
  @spec purchase(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()}
  def purchase(amount, card_token, opts) do
    param = [{:token, card_token} | amount_params(amount)]
    commit(:post, "transactions", param, opts)
  end

  @doc """
  Refunds the `amount` to the customer's account with reference to a prior transfer.

  PAYMILL processes a full or partial refund worth `amount`, where `transaction_id`
  references a previous `purchase/3` or `capture/3` result.

  Multiple partial refunds are allowed on the same `transaction_id` till all the
  captured/purchased amount has been refunded.

  ## Example

  The following example shows how one would refund a previous purchase (and
  similarily for captures).
  ```
  iex> transaction_id = purchase_result.id
  iex> amount = Money.new(4200, :EUR)
  iex> Gringotts.refund(Gringotts.Gateways.Paymill, amount, transaction_id)
  ```
  """
  @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()}
  def refund(amount, id, opts) do
    {_, int_value, _} = Money.to_integer(amount)
    commit(:post, "refunds/#{id}", [amount: int_value], opts)
  end

  @doc """
  Voids the referenced authorization.

  Attempts a reversal of the a previous `authorize/3` referenced by
  `preauth_id`.

  ## Example

  The following example shows how one would void a previous authorization.
  ```
  iex> preauth_id = auth_result.id
  iex> Gringotts.void(Gringotts.Gateways.Paymill, preauth_id)
  ```
  """
  @spec void(String.t(), keyword) :: {:ok | :error, Response.t()}
  def void(id, opts) do
    commit(:delete, "preauthorizations/#{id}", [], opts)
  end

  defp commit(method, endpoint, params, opts) do
    method
    |> HTTPoison.request(base_url(opts) <> endpoint, {:form, params}, headers(opts))
    |> respond()
  end

  @response_code_paths [
    ~w[transaction response_code],
    ~w[data response_code],
    ~w[data transaction response_code]
  ]
  @token_paths [~w[id], ~w[data id]]
  @reason_paths [~w[error], ~w[exception]]
  @fraud_paths [
    ~w[transaction is_fraud],
    ~w[data transaction is_fraud],
    ~w[data transaction is_markable_as_fraud],
    ~w[data is_markable_as_fraud]
  ]

  defp get_either(collection, paths) do
    paths
    |> Stream.map(&get_in(collection, &1))
    |> Enum.find(fn x -> x != nil end)
  end

  defp respond({:ok, %{status_code: 200, body: body}}) do
    case Jason.decode(body) do
      {:ok, parsed_resp} ->
        gateway_code = get_either(parsed_resp, @response_code_paths)

        status = if gateway_code in [20_000, 50_810], do: :ok, else: :error

        {status,
         %Response{
           id: get_either(parsed_resp, @token_paths),
           token: parsed_resp["transaction"]["identification"]["uniqueId"],
           status_code: 200,
           gateway_code: gateway_code,
           reason: get_either(parsed_resp, @reason_paths),
           message: @response_code[gateway_code],
           raw: body,
           fraud_review: get_either(parsed_resp, @fraud_paths)
         }}

      {:error, _} ->
        IO.inspect(body)

        {:error,
         %Response{status_code: 200, raw: body, reason: "could not parse paymill response"}}
    end
  end

  defp respond({:ok, %{status_code: status_code, body: body}}) do
    {:error,
     %Response{
       status_code: status_code,
       raw: body
     }}
  end

  defp respond({:error, %HTTPoison.Error{} = error}) do
    {
      :error,
      Response.error(
        reason: "network related failure",
        message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]"
      )
    }
  end

  defp headers(opts) do
    [
      {"Authorization", "Basic #{Base.encode64(get_in(opts, [:config, :private_key]))}"}
      | @headers
    ]
  end

  defp amount_params(money) do
    {currency, int_value, _} = Money.to_integer(money)
    [amount: int_value, currency: currency]
  end

  defp base_url(opts), do: opts[:config][:test_url] || @base_url
end