lib/mpgs.ex

defmodule Mpgs do
  @moduledoc """
  This module deals with the MasterCard Payment Gateway Service (MPGS).

  Check the MPGS API documentation for more details:

  https://ap-gateway.mastercard.com/api/documentation/apiDocumentation/rest-json/version/latest/api.html?locale=en_US
  """

  @api_url "https://ap-gateway.mastercard.com/api/rest/version"
  @api_version "74"

  @doc """
  Use this function to start the payment process.

  The value of the function is a piece of HTML that should be rendered on the client's browser.

  This funciton takes a map and returns a tuple.

  Available map keys:
  - `api_username`. Required. Optional if there is an env variable called `MPGS_API_USERNAME`.
  - `api_password`. Required. Optional if there is an env variable called `MPGS_API_PASSWORD`.
  - `api_base`. Optional. Defaults to #{@api_url}.
  - `api_version`. Optional. Defaults to #{@api_version}.
  - `api_merchant`. Required.
  - `session`. Optional. A new session will be created if this key does not exist.
  - `currency`. Optional. The 3-digit currency code. Defaults to KWD.
  - `amount`. Required. It must be a number that indicates the total amount to be paid.
  - `card_number`. Required. The 16-digit card number used for the payment. Optional if the session is provided and it already contains the card data.
  - `expiry_month`. Required. The card's 2-digit expiry month.  Optional if the session is provided and it already contains the card data.
  - `expiry_year`. Required. The card's 2-digit expiry year.  Optional if the session is provided and it already contains the card data.
  - `security_code`. Required. The card's 3-digit (or 4-digit) CVV code.  Optional if the session is provided and it already contains the card data.
  - `order`. Required. The order number.
  - `trx`. Required. The transaction number.
  - `response_url`. Required. The URL that will receive the authentication response from the MPGS gateway.
  - `browser_agent`. Required. The user's browser agent string.
  - `full_page_redirect`. Optional. If `true`, this function will return a complete html document which can be rendered as a full page.

  If you are not PCI compliant, you should provide a session which already contains card details.

  This session is obtained by the MPGS gateway through the [Hosted Session Integration](https://ap-gateway.mastercard.com/api/documentation/integrationGuidelines/hostedSession/integrationModelHostedSession.html) guide:

  Return values:
  - `{:ok, session, html}`. The `html` value should be rendered on the client's web browser. The `session` should be used with the `capture_payment/1` function.
  - `{:error, exception}`. An error occurred while processing the authentication request.

  Example:

  ```
  params = %{
    api_username: "my-mpgs-username",
    api_password: "my-mpgs-password",
    api_merchant: "my-mpgs-merchant",
    amount: "15",
    currency: "KWD",

    # include a session which contains card details:
    session: "SESSION2483924783297489234",
    # or include card details:
    card_number: "1234567890123456",
    expiry_month: "10",
    expiry_year: "25",
    security_code: "123",

    order: "0123456789",
    trx: "0987654321",
    response_url: "http://example.com/payment/mpgs/response/",
    browser_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...Version/16.5.2 Safari/605.1.15",
    full_page_redirect: true,
  }

  {:ok, session, html} = authenticate_payment(params)
  # Send and render the html on client's browser.
  # After a successful or a failed authentication attempt by the user,
  # call the capture_payment/1 function.
  params = Map.put(params, :session, session)
  {:ok, result} = capture_payment(params)
  ```
  """
  @spec authenticate_payment(map()) :: {:ok, String.t(), String.t()}
  def authenticate_payment(params) do
    case get_local_var(params, :session) do
      nil ->
        {:ok, result} = create_session(params)
        session = result["session"]["id"]
        params = Map.put(params, :session, session)
        {:ok, _result} = update_session_data(params)

      _session ->
        {:ok, _result} = update_session_data(params, false)
    end

    {:ok, _result} = update_session_data(params)
    {:ok, _result} = initiate_authentication(params)
    {:ok, result} = authenticate_payer(params)

    html =
      if Map.get(params, :full_page_redirect, false) do
        rebuild_html_form(result["authentication"]["redirect"]["html"])
      else
        result["authentication"]["redirect"]["html"]
      end

    session = get_local_var(params, :session)
    {:ok, session, html}
  end

  defp create_session(params) do
    url = build_url(params, "session")
    headers = build_headers(params)
    default_session = %{"session" => %{"authenticationLimit" => 25}}

    payload =
      params
      |> get_local_var(:session_payload, default_session)
      |> Jason.encode!()

    Finch.build(:post, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  defp update_session_data(params, include_card_details \\ true) do
    session = get_local_var(params, :session)
    currency = get_local_var(params, :currency, "KWD") |> String.upcase()
    amount = get_local_var(params, :amount)
    card_number = get_local_var(params, :card_number)
    expiry_month = get_local_var(params, :expiry_month)
    expiry_year = get_local_var(params, :expiry_year)
    security_code = get_local_var(params, :security_code)

    headers = build_headers(params)
    url = build_url(params, "session/#{session}")

    payload =
      %{
        "order" => %{
          "amount" => amount,
          "currency" => currency
        }
      }

    payload =
      case include_card_details do
        true ->
          funds = %{
            "type" => "CARD",
            "provided" => %{
              "card" => %{
                "number" => card_number,
                "expiry" => %{
                  "month" => expiry_month,
                  "year" => expiry_year
                },
                "securityCode" => security_code
              }
            }
          }

          Map.put(payload, "sourceOfFunds", funds)

        false ->
          payload
      end
      |> Jason.encode!()

    Finch.build(:put, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  defp initiate_authentication(params) do
    session = get_local_var(params, :session)
    currency = get_local_var(params, :currency, "KWD") |> String.upcase()
    headers = build_headers(params)
    order = get_local_var(params, :order)
    trx = get_local_var(params, :trx)
    url = build_url(params, "order/#{order}/transaction/auth-#{trx}")

    payload =
      %{
        "apiOperation" => "INITIATE_AUTHENTICATION",
        "session" => %{"id" => session},
        "order" => %{
          "currency" => currency
        },
        "authentication" => %{
          "acceptVersions" => "3DS1,3DS2",
          "purpose" => "PAYMENT_TRANSACTION",
          "channel" => "PAYER_BROWSER"
        }
      }
      |> Jason.encode!()

    Finch.build(:put, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  defp authenticate_payer(params) do
    session = get_local_var(params, :session)
    currency = get_local_var(params, :currency, "KWD") |> String.upcase()
    amount = get_local_var(params, :amount)
    browser_agent = get_local_var(params, :browser_agent)
    headers = build_headers(params)
    order = get_local_var(params, :order)
    trx = get_local_var(params, :trx)
    response_url = get_local_var(params, :response_url)

    payload =
      %{
        "apiOperation" => "AUTHENTICATE_PAYER",
        "session" => %{"id" => session},
        "order" => %{
          "amount" => amount,
          "currency" => currency
        },
        "device" => %{
          "browser" => browser_agent,
          "browserDetails" => %{
            "3DSecureChallengeWindowSize" => "390_X_400",
            "acceptHeaders" => "text/html",
            "colorDepth" => "24",
            "javaEnabled" => "true",
            "javaScriptEnabled" => "true",
            "language" => "en",
            "screenHeight" => "400",
            "screenWidth" => "600",
            "timeZone" => "180"
          }
        },
        "authentication" => %{
          "redirectResponseUrl" => response_url
        }
      }
      |> Jason.encode!()

    url = build_url(params, "order/#{order}/transaction/auth-#{trx}")

    Finch.build(:put, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  @doc """
  Use this function to capture (charge) the card.

  This function must be used after the `authenticate_payment/1` function.

  Available map keys:
  - `session`. Required. This is obtained from calling `authenticate_payment/1` first.
  - `currency`. Optional. Defaults to `KWD`.
  - `amount`. Required. The same amount used with the `authenticate_payment/1` function.
  - `order`. Required. The same order number used with the `authenticate_payment/1` function.
  - `trx`. Required. The same transaction number used with the `authenticate_payment/1` function.
  - `api_username`. Required. Optional if there is an env variable called `MPGS_API_USERNAME`.
  - `api_password`. Required. Optional if there is an env variable called `MPGS_API_PASSWORD`.
  - `api_base`. Optional. Defaults to #{@api_url}.
  - `api_version`. Optional. Defaults to #{@api_version}.
  - `api_merchant`. Required.
  """
  def capture_payment(params, extra_params \\ %{}) do
    # session = get_local_var(params, :session)
    currency = get_local_var(params, :currency, "KWD") |> String.upcase()
    amount = get_local_var(params, :amount)
    order = get_local_var(params, :order)
    trx = get_local_var(params, :trx)

    payload =
      %{
        "apiOperation" => "PAY",
        # "session" => %{"id" => session},
        # "authentication" => %{"transactionId" => "auth-" <> trx},
        "transaction" => %{"reference" => order},
        "order" => %{
          "currency" => currency,
          "amount" => amount,
          "reference" => order
        }
      }
      |> Map.merge(extra_params)
      |> Jason.encode!()

    url = build_url(params, "order/#{order}/transaction/#{trx}")
    headers = build_headers(params)

    Finch.build(:put, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  @doc """
  Retrieves details about an existing transaction.

  Available map keys:
  - `order`. Required. The same order number used with the `capture_payment/1` function.
  - `trx`. Required. The same transaction number used with the `capture_payment/1` function.
  - `api_username`. Required. Optional if there is an env variable called `MPGS_API_USERNAME`.
  - `api_password`. Required. Optional if there is an env variable called `MPGS_API_PASSWORD`.
  - `api_base`. Optional. Defaults to #{@api_url}.
  - `api_version`. Optional. Defaults to #{@api_version}.
  - `api_merchant`. Required.
  """
  def retrieve_transaction(params) do
    order = get_local_var(params, :order)
    trx = get_local_var(params, :trx)
    headers = build_headers(params)
    url = build_url(params, "order/#{order}/transaction/#{trx}")

    Finch.build(:get, url, headers)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  defp rebuild_html_form(html) do
    html
    |> String.replace(~s/target="challengeFrame"/, "")
    |> String.replace("iframe", "div")
    |> String.replace(~s/target="redirectTo3ds1Frame"/, "")
    |> then(fn content -> "<html><body>#{content}</body></html>" end)
  end

  @doc """
  Use this function to refund a paid transaction.

  Available map keys:
  - `order`. Required. The order number used with the `capture_payment/1` function.
  - `trx`. Required. The transaction number used with the `capture_payment/1` function.
  - `amount`. Required.
  - `api_username`. Required. Optional if there is an env variable called `MPGS_API_USERNAME`.
  - `api_password`. Required. Optional if there is an env variable called `MPGS_API_PASSWORD`.
  - `api_base`. Optional. Defaults to #{@api_url}.
  - `api_version`. Optional. Defaults to #{@api_version}.
  - `api_merchant`. Required.

  Example:
  ```
  order_params = %{
    api_username: "my-mpgs-username",
    api_password: "my-mpgs-password",
    api_merchant: "my-mpgs-merchant",
    order: "0123456789",
    trx: "0987654321",
    amount: "15",
    currency: "KWD"
  }

  {:ok, response} = Mpgs.refund_transaction(params)
  response["result"] #=> "SUCCESS"
  response["order"]["status"] #=> "REFUNDED"
  ```
  """
  def refund_transaction(params) do
    order = get_local_var(params, :order)
    trx = get_local_var(params, :trx)
    amount = get_local_var(params, :amount)
    currency = get_local_var(params, :currency, "KWD")
    headers = build_headers(params)
    url = build_url(params, "order/#{order}/transaction/ref-#{trx}")

    payload =
      %{
        "apiOperation" => "REFUND",
        "transaction" => %{
          "amount" => amount,
          "currency" => currency
        }
      }
      |> Jason.encode!()

    Finch.build(:put, url, headers, payload)
    |> Finch.request(MpgsFinch)
    |> parse_response()
  end

  @doc """
  Returns `true` if the MPGS API is operational, `false` otherwise.

  Available map keys:
  - `api_base`. Optional. Defaults to #{@api_url}.
  - `api_version`. Optional. Defaults to #{@api_version}.

  ```
  Mpgs.check_connectivity()
  true
  ```
  """
  def check_connectivity(params \\ %{}) do
    base = get_local_var(params, :api_base) || get_env_var("MPGS_API_BASE", @api_url)
    version = get_local_var(params, :api_version) || get_env_var("MPGS_API_VERSION", @api_version)
    url = "#{base}/#{version}/information"

    Finch.build(:get, url)
    |> Finch.request(MpgsFinch)
    |> parse_response()
    |> then(fn
      {:ok, json} -> json["status"] == "OPERATING"
      _ -> false
    end)
  end

  defp build_url(options, path) do
    base = get_local_var(options, :api_base) || get_env_var("MPGS_API_BASE", @api_url)

    version =
      get_local_var(options, :api_version) || get_env_var("MPGS_API_VERSION", @api_version)

    merchant = get_local_var(options, :api_merchant) || get_env_var("MPGS_API_MERCHANT")

    "#{base}/#{version}/merchant/#{merchant}/#{path}"
  end

  defp build_headers(options) do
    username = get_local_var(options, :api_username) || get_env_var("MPGS_API_USERNAME")
    password = get_local_var(options, :api_password) || get_env_var("MPGS_API_PASSWORD")
    encoded_credentials = Base.encode64("#{username}:#{password}")

    [
      {"Authorization", "Basic #{encoded_credentials}"},
      {"Content-Type", "application/json"},
      {"Accept", "application/json"}
    ]
  end

  defp get_local_var(params, name, default \\ nil) do
    name_str = to_string(name)
    Map.get(params, name) || Map.get(params, name_str) || default
  end

  defp get_env_var(name, default \\ nil), do: apply(System, :get_env, [name, default])

  defp parse_response({:ok, %Finch.Response{status: status, body: body}})
       when status in 200..299 do
    {:ok, Jason.decode!(body)}
  end

  defp parse_response({:ok, %Finch.Response{body: body}}), do: {:error, Jason.decode!(body)}

  defp parse_response({:error, exception}), do: {:error, exception}
end