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)

  @supported_objects ~w(
    account
    account_link
    application_fee
    fee_refund
    balance
    balance_transaction
    bank_account
    billing_portal.session
    capability
    card
    charge
    checkout.session
    country_spec
    coupon
    credit_note
    credit_note_line_item
    customer
    customer_balance_transaction
    discount
    dispute
    ephemeral_key
    radar.early_fraud_warning
    event
    external_account
    file
    file_link
    identity.verification_session
    identity.verification_report
    invoice
    invoiceitem
    issuing.authorization
    issuing.card
    issuing.cardholder
    issuing.transaction
    line_item
    list
    login_link
    mandate
    oauth
    order
    order_item
    order_return
    payment_intent
    payment_link
    payment_method
    payout
    person
    plan
    price
    product
    promotion_code
    recipient
    refund
    reporting.report_run
    reporting.report_type
    review
    search_result
    setup_intent
    sku
    source
    subscription
    subscription_item
    subscription_schedule
    tax_rate
    tax_id
    topup
    terminal.connection_token
    terminal.location
    terminal.reader
    test_helpers.test_clock
    transfer
    transfer_reversal
    token
    usage_record
    usage_record_summary
    webhook_endpoint
  )

  @no_convert_maps ~w(metadata supported_bank_account_currencies)

  @doc """
  Returns a list of structs to be used for providing JSON-encoders.

  ## Examples

  Say you are using Jason to encode your JSON, you can provide the following protocol,
  to directly encode all structs of this library into JSON.

  ```
  for struct <- Stripe.Converter.structs() do
    defimpl Jason.Encoder, for: struct do
      def encode(value, opts) do
        Jason.Encode.map(Map.delete(value, :__struct__), opts)
      end
    end
  end
  ```
  """
  def structs() do
    (@supported_objects -- @no_convert_maps)
    |> Enum.map(&Stripe.Util.object_name_to_module/1)
  end

  @spec convert_value(any) :: any
  defp convert_value(%{"object" => object_name} = value) when is_binary(object_name) do
    case Enum.member?(@supported_objects, 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.warn("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.warn(message)
      end

      :ok
    end
  end
end