lib/stripe/converter.ex

defmodule Stripe.Converter do
  @doc """
  Takes a result map or list of maps from a Stripe response and returns a
  struct (e.g. `%Stripe.Card{}`) or list of structs.

  If the result is not a supported Stripe object, it just returns a plain map
  with atomized keys.
  """

  @spec convert_result(%{String.t() => any}) :: struct
  def convert_result(result), do: convert_value(result)

  @no_convert_maps ~w(metadata supported_bank_account_currencies)

  @spec convert_value(any) :: any
  defp convert_value(%{"object" => object_name} = value) when is_binary(object_name) do
    case known_struct?(object_type_to_struct(object_name)) do
      true ->
        convert_stripe_object(value)

      false ->
        warn_unknown_object(value)
        convert_map(value)
    end
  end

  defp convert_value(value) when is_map(value), do: convert_map(value)
  defp convert_value(value) when is_list(value), do: convert_list(value)
  defp convert_value(value), do: value

  @spec convert_map(map) :: map
  defp convert_map(value) do
    Enum.reduce(value, %{}, fn {key, value}, acc ->
      Map.put(acc, String.to_atom(key), convert_value(value))
    end)
  end

  @spec convert_stripe_object(%{String.t() => any}) :: struct
  defp convert_stripe_object(%{"object" => object_name} = value) do
    module = Stripe.Util.object_name_to_module(object_name)
    struct_keys = Map.keys(module.__struct__) |> List.delete(:__struct__)
    check_for_extra_keys(struct_keys, value)

    processed_map =
      struct_keys
      |> Enum.reduce(%{}, fn key, acc ->
        string_key = to_string(key)

        converted_value =
          case string_key do
            string_key when string_key in @no_convert_maps -> Map.get(value, string_key)
            _ -> Map.get(value, string_key) |> convert_value()
          end

        Map.put(acc, key, converted_value)
      end)
      |> module.__from_json__()

    struct(module, processed_map)
  end

  @spec convert_list(list) :: list
  defp convert_list(list), do: list |> Enum.map(&convert_value/1)

  if Mix.env() == :prod do
    defp warn_unknown_object(_), do: :ok
  else
    defp warn_unknown_object(%{"object" => object_name}) do
      require Logger

      Logger.warning("Unknown object received: #{object_name}")
    end
  end

  if Mix.env() == :prod do
    defp check_for_extra_keys(_, _), do: :ok
  else
    defp check_for_extra_keys(struct_keys, map) do
      require Logger

      map_keys =
        map
        |> Map.keys()
        |> Enum.map(&String.to_atom/1)
        |> MapSet.new()

      struct_keys =
        struct_keys
        |> MapSet.new()

      extra_keys =
        map_keys
        |> MapSet.difference(struct_keys)
        |> Enum.to_list()

      unless Enum.empty?(extra_keys) do
        object = Map.get(map, "object")

        module_name =
          object
          |> Stripe.Util.object_name_to_module()
          |> Stripe.Util.module_to_string()

        details = "#{module_name}: #{inspect(extra_keys)}"
        message = "Extra keys were received but ignored when converting #{details}"
        Logger.warning(message)
      end

      :ok
    end
  end

  defp object_type_to_struct(object) do
    module = object |> String.split(".") |> Enum.map(&Macro.camelize/1)
    Module.concat(["Stripe" | module])
  end

  defp known_struct?(struct) do
    Code.ensure_loaded?(struct) && function_exported?(struct, :__struct__, 0)
  end
end