lib/dwolla/utils.ex

defmodule Dwolla.Utils do
  @moduledoc """
  Utility functions.
  """

  require Logger

  @doc """
  Encodes HTTP request.
  """
  @spec encode_params(map, map) :: String.t()
  def encode_params(params, cred \\ %{}) do
    params
    |> Map.merge(cred)
    |> Map.to_list()
    |> Enum.map_join("&", fn x -> pair(x) end)
  end

  defp pair({key, value}) do
    param_name = to_string(key) |> URI.encode_www_form()

    param_value =
      cond do
        is_map(value) ->
          Poison.encode!(value)

        is_list(value) ->
          Enum.map_join(value, "|", fn x -> x end) |> URI.encode_www_form()

        true ->
          to_string(value) |> URI.encode_www_form()
      end

    "#{param_name}=#{param_value}"
  end

  @doc """
  Validates parameter payload against a list of required fields.
  """
  @spec validate_params(map, list) :: :ok | :error
  def validate_params(params, fields) do
    Map.keys(params)
    |> Enum.map(&to_string/1)
    |> do_validate_params(fields)
  end

  defp do_validate_params(_param_fields, []), do: :ok

  defp do_validate_params(param_fields, [field | t]) do
    case field in param_fields do
      true ->
        do_validate_params(param_fields, t)

      _ ->
        :error
    end
  end

  @doc """
  Converts keys in Dwolla response to Elixir-friendly snake case.
  """
  def to_snake_case(response) when is_binary(response), do: response

  def to_snake_case(response) when is_map(response) do
    response
    |> Map.to_list()
    |> Stream.map(fn {k, v} ->
      cond do
        is_map(v) ->
          {Recase.to_snake(k), to_snake_case(v)}

        is_list(v) ->
          {Recase.to_snake(k), Enum.map(v, &to_snake_case/1)}

        true ->
          {Recase.to_snake(k), v}
      end
    end)
    |> Enum.into(%{})
  end

  @doc """
  Converts request payload to Dwolla-friendly camel case.
  """
  def to_camel_case(payload) when is_map(payload) do
    payload
    |> Map.to_list()
    |> Stream.map(fn {k, v} -> {to_string(k), v} end)
    |> Stream.map(fn {k, v} ->
      if is_map(v) do
        {key_to_camel_case(k), to_camel_case(v)}
      else
        {key_to_camel_case(k), v}
      end
    end)
    |> Enum.into(%{})
  end

  def to_camel_case(payload) do
    payload
  end

  defp key_to_camel_case(k) when k in ["_links", "_embedded"] do
    k
  end

  defp key_to_camel_case(k) do
    Recase.to_camel(k)
  end

  @doc """
  Handles HTTP response from Dwolla.
  """
  @spec handle_resp({:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()}, atom) ::
          {:ok, any} | {:error, HTTPoison.Error.t() | Dwolla.Errors.t() | any}
  def handle_resp({:ok, %{body: {:invalid, body}}}, _schema) do
    {:error, body}
  end

  def handle_resp({:ok, %{status_code: 200, body: %{"error" => _} = body}}, _schema) do
    {:error, %Dwolla.Errors{code: body["error"], message: body["error_description"]}}
  end

  def handle_resp({:ok, %{status_code: code, body: ""} = resp}, _schema) when code in 200..201 do
    {:ok, get_resource_id_from_headers(resp.headers)}
  end

  def handle_resp({:ok, %{status_code: code} = resp}, schema) when code in 200..201 do
    {:ok, map_body(resp.body, schema)}
  end

  def handle_resp({:ok, resp}, _schema) do
    {:error, format_error(resp.body)}
  end

  def handle_resp({:error, error}, _schema) do
    {:error, error}
  end

  defp map_body(%{"_embedded" => %{"beneficial-owners" => beneficial_owners}}, schema) do
    Enum.map(beneficial_owners, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"business-classifications" => business_classifications}}, schema) do
    Enum.map(business_classifications, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"customers" => customers}}, schema) do
    Enum.map(customers, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"funding-sources" => funding_sources}}, schema) do
    Enum.map(funding_sources, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"transfers" => transfers}, "total" => total}, schema) do
    transfers = Enum.map(transfers, &map_body(&1, schema))
    %{transfers: transfers, total: total}
  end

  defp map_body(%{"_embedded" => %{"webhook-subscriptions" => webhook_subs}}, schema) do
    Enum.map(webhook_subs, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"webhooks" => webhooks}}, schema) do
    Enum.map(webhooks, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"retries" => retries}}, schema) do
    Enum.map(retries, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"events" => events}}, schema) do
    Enum.map(events, &map_body(&1, schema))
  end

  defp map_body(%{"_embedded" => %{"documents" => documents}}, schema) do
    Enum.map(documents, &map_body(&1, schema))
  end

  defp map_body(body, :beneficial_owner) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.BeneficialOwner{}})
  end

  defp map_body(body, :business_classification) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.BusinessClassification{}})
  end

  defp map_body(body, :client_token) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.ClientToken{}})
  end

  defp map_body(body, :customer) do
    customer =
      body
      |> to_snake_case()
      |> Poison.Decode.transform(%{as: %Dwolla.Customer{}})

    verify_beneficial_ownership = get_customer_verify_beneficial_ownership_from_body(body)
    certify_beneficial_ownership = get_customer_certify_beneficial_ownership_from_body(body)

    customer
    |> Map.put(:verify_beneficial_ownership, verify_beneficial_ownership)
    |> Map.put(:certify_beneficial_ownership, certify_beneficial_ownership)
  end

  defp map_body(body, :funding_source) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.FundingSource{}})
  end

  defp map_body(body, :on_demand_authorization) do
    on_demand_authorization =
      body
      |> to_snake_case()
      |> Poison.Decode.transform(%{as: %Dwolla.OnDemandAuthorization{}})

    on_demand_auth_resource_id = get_resource_id_from_body(body)
    %{on_demand_authorization | id: on_demand_auth_resource_id}
  end

  defp map_body(%{"_links" => links} = body, :transfer) do
    transfer =
      body
      |> to_snake_case()
      |> Poison.Decode.transform(%{
        as: %Dwolla.Transfer{
          amount: %Dwolla.Transfer.Amount{},
          metadata: %Dwolla.Transfer.Metadata{}
        }
      })

    can_cancel = Map.has_key?(links, "cancel")
    [source_resource, source_resource_id] = get_transfer_source_from_body(body)
    [dest_resource, dest_resource_id] = get_transfer_destination_from_body(body)
    source_funding_source_id = get_transfer_source_funding_source_from_body(body)

    transfer
    |> Map.put(:source_resource, source_resource)
    |> Map.put(:source_resource_id, source_resource_id)
    |> Map.put(:dest_resource, dest_resource)
    |> Map.put(:dest_resource_id, dest_resource_id)
    |> Map.put(:source_funding_source_id, source_funding_source_id)
    |> Map.put(:can_cancel, can_cancel)
  end

  defp map_body(body, :event) do
    event =
      body
      |> to_snake_case()
      |> Poison.Decode.transform(%{as: %Dwolla.Event{}})

    resource = get_resource_from_body(body)
    %{event | resource: resource}
  end

  defp map_body(body, :webhook_subscription) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.WebhookSubscription{}})
  end

  defp map_body(body, :webhook) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{
      as: %Dwolla.Webhook{
        attempts: [
          %Dwolla.Webhook.Attempt{
            request: %Dwolla.Webhook.Attempt.Request{},
            response: %Dwolla.Webhook.Attempt.Response{}
          }
        ]
      }
    })
  end

  defp map_body(body, :retry) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.Webhook.Retry{}})
  end

  defp map_body(body, :failure) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.Transfer.Failure{}})
  end

  defp map_body(%{"balance" => balance} = body, :balance) do
    body
    |> to_snake_case()
    |> Map.merge(to_snake_case(balance))
    |> Poison.Decode.transform(%{as: %Dwolla.FundingSource.Balance{}})
  end

  defp map_body(body, :token) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.Token{}})
  end

  defp map_body(body, :document) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.Document{}})
  end

  defp get_customer_verify_beneficial_ownership_from_body(%{"_links" => %{"verify-beneficial-ownership" => %{"href" => _}}}),
    do: true

  defp get_customer_verify_beneficial_ownership_from_body(_),
    do: false

  defp get_customer_certify_beneficial_ownership_from_body(%{"_links" => %{"certify-beneficial-ownership" => %{"href" => _}}}),
    do: true

  defp get_customer_certify_beneficial_ownership_from_body(_),
    do: false

  defp get_transfer_source_from_body(%{"_links" => %{"source" => %{"href" => url}}} = _body) do
    url
    |> String.split("/")
    |> Enum.take(-2)
  end

  defp get_transfer_destination_from_body(
         %{"_links" => %{"destination" => %{"href" => url}}} = _body
       ) do
    url
    |> String.split("/")
    |> Enum.take(-2)
  end

  defp get_transfer_source_funding_source_from_body(
         %{"_links" => %{"source-funding-source" => %{"href" => url}}} = _body
       ) do
    get_resource_id_from_url(url)
  end

  defp get_transfer_source_funding_source_from_body(_) do
    nil
  end

  defp get_resource_id_from_url(url) do
    url
    |> String.split("/")
    |> List.last()
  end

  defp get_resource_id_from_body(
         %{"_links" => %{"self" => %{"href" => url}}} = _body
       ) do
    get_resource_id_from_url(url)
  end

  defp get_resource_from_body(%{"_links" => %{"resource" => %{"href" => url}}} = _body) do
    url
    |> String.split("/")
    |> Enum.take(-2)
    |> List.first()
  end

  defp get_resource_id_from_headers(headers) do
    headers
    |> get_resource_from_headers()
    |> extract_id_from_resource()
  end

  defp get_resource_from_headers(headers) do
    headers |> Enum.find(fn {k, _} -> k == "Location" end)
  end

  defp extract_id_from_resource({"Location", resource}) do
    id = get_resource_id_from_url(resource)
    %{id: id}
  end

  defp format_error(%{"_embedded" => %{"errors" => errors}} = body) do
    new_errors = Enum.map(errors, &Map.take(&1, ["code", "message", "path"]))

    body
    |> Map.drop(["_embedded"])
    |> Map.put("errors", new_errors)
    |> format_error()
  end

  defp format_error(body) do
    body
    |> to_snake_case()
    |> Poison.Decode.transform(%{as: %Dwolla.Errors{errors: [%Dwolla.Errors.Error{}]}})
  end

  @doc """
  Creates a idempotency header with an MD5 hash of the parameters submitted or
  provided binary value.
  """
  @spec idempotency_header(map) :: map
  def idempotency_header(idempotency_key) when is_binary(idempotency_key) do
    Map.new()
    |> Map.put("Idempotency-Key", idempotency_key)
  end

  def idempotency_header(params) do
    Map.new()
    |> Map.put("Idempotency-Key", generate_idempotency_key(params))
  end

  defp generate_idempotency_key(params) do
    :crypto.hash(:md5, encode_params(params))
    |> Base.encode16()
    |> String.downcase()
  end
end