Skip to main content

lib/revenue_cat.ex

defmodule RevenueCat do
  @moduledoc """
  RevenueCat V2 API client focused on entitlement checks and customer deletion.

  Supports global config, per-call overrides, and reusable client structs.
  """

  @behaviour RevenueCat.Client

  require Logger

  @api_origin "https://api.revenuecat.com"
  @api_version_path "/v2"
  @default_base_url @api_origin <> @api_version_path

  @type option_key ::
          :secret_api_key
          | :project_id
          | :base_url
          | :req_options

  @type options :: [{option_key(), term()}]

  @type t :: %__MODULE__{
          secret_api_key: nil | String.t(),
          project_id: nil | String.t(),
          base_url: String.t(),
          req_options: keyword()
        }

  defstruct secret_api_key: nil,
            project_id: nil,
            base_url: @default_base_url,
            req_options: []

  @doc """
  Builds a reusable client struct.

  Options:

  - `:secret_api_key`
  - `:project_id`
  - `:base_url` (defaults to RevenueCat V2 API base URL)
  - `:req_options` (passed through to `Req.get/2` and `Req.delete/2`)
  """
  @spec new(options()) :: t()
  def new(opts \\ []) when is_list(opts) do
    %__MODULE__{
      secret_api_key: normalize_nullable_string(Keyword.get(opts, :secret_api_key)),
      project_id: normalize_nullable_string(Keyword.get(opts, :project_id)),
      base_url: normalize_base_url(Keyword.get(opts, :base_url)),
      req_options: normalize_req_options(Keyword.get(opts, :req_options, []))
    }
  end

  @doc """
  Checks whether a customer currently has an active entitlement.

  `configured_entitlement` may be an entitlement ID, lookup key, or display name.
  Returns `{:ok, true | false}` on successful API resolution.
  """
  @impl true
  def has_active_entitlement(app_user_id, configured_entitlement)
      when is_binary(app_user_id) and is_binary(configured_entitlement) do
    has_active_entitlement(app_user_id, configured_entitlement, [])
  end

  @doc """
  Checks active entitlement using per-call options.

  Options override app config for this call.
  """
  @impl true
  @spec has_active_entitlement(String.t(), String.t(), options()) ::
          {:ok, boolean()} | {:error, term()}
  def has_active_entitlement(app_user_id, configured_entitlement, opts)
      when is_binary(app_user_id) and is_binary(configured_entitlement) and is_list(opts) do
    with {:ok, client} <- client_from_opts(opts) do
      has_active_entitlement(client, app_user_id, configured_entitlement)
    end
  end

  @spec has_active_entitlement(t(), String.t(), String.t()) ::
          {:ok, boolean()} | {:error, term()}
  def has_active_entitlement(%__MODULE__{} = client, app_user_id, configured_entitlement)
      when is_binary(app_user_id) and is_binary(configured_entitlement) do
    with {:ok, secret_api_key} <- revenuecat_secret_api_key(client),
         {:ok, project_id} <- revenuecat_project_id(client),
         customer_id when is_binary(customer_id) and customer_id != "" <- String.trim(app_user_id),
         {:ok, active_entitlement_ids} <-
           fetch_active_entitlement_ids(client, secret_api_key, project_id, customer_id),
         {:ok, entitlement_id} <-
           resolve_configured_entitlement_id(
             client,
             secret_api_key,
             project_id,
             configured_entitlement,
             active_entitlement_ids
           ) do
      Logger.info(
        "RevenueCat active entitlements app_user_id=#{customer_id} configured_entitlement=#{inspect(configured_entitlement)} resolved_entitlement_id=#{inspect(entitlement_id)} active_ids=#{inspect(MapSet.to_list(active_entitlement_ids))}"
      )

      {:ok, MapSet.member?(active_entitlement_ids, entitlement_id)}
    else
      "" -> {:error, :invalid_response}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Deletes a customer in RevenueCat.

  Returns `:ok` when RevenueCat responds with `200`, `204`, or `404`.
  """
  @impl true
  def delete_customer(app_user_id) when is_binary(app_user_id) do
    delete_customer(app_user_id, [])
  end

  @doc """
  Deletes a customer using per-call options.

  Options override app config for this call.
  """
  @impl true
  @spec delete_customer(String.t(), options()) :: :ok | {:error, term()}
  def delete_customer(app_user_id, opts) when is_binary(app_user_id) and is_list(opts) do
    with {:ok, client} <- client_from_opts(opts) do
      delete_customer(client, app_user_id)
    end
  end

  @spec delete_customer(t(), String.t()) :: :ok | {:error, term()}
  def delete_customer(%__MODULE__{} = client, app_user_id) when is_binary(app_user_id) do
    with {:ok, secret_api_key} <- revenuecat_secret_api_key(client),
         {:ok, project_id} <- revenuecat_project_id(client),
         customer_id when is_binary(customer_id) and customer_id != "" <- String.trim(app_user_id) do
      url =
        "/projects/#{URI.encode_www_form(project_id)}/customers/#{URI.encode_www_form(customer_id)}"
        |> api_url(client)

      headers = [authorization: "Bearer #{secret_api_key}", accept: "application/json"]

      case Req.delete(url, req_options(client, headers)) do
        {:ok, %Req.Response{status: status}} when status in [200, 204] ->
          :ok

        {:ok, %Req.Response{status: 404}} ->
          Logger.info("RevenueCat customer already deleted app_user_id=#{customer_id}")
          :ok

        {:ok, %Req.Response{status: status, body: body}} ->
          Logger.warning(
            "RevenueCat customer delete failed app_user_id=#{customer_id} status=#{status} body=#{inspect(body)}"
          )

          {:error, :upstream_error}

        {:error, %Req.TransportError{} = error} ->
          Logger.warning("RevenueCat customer delete transport error: #{inspect(error.reason)}")
          {:error, :upstream_unavailable}
      end
    else
      "" -> {:error, :invalid_response}
      {:error, reason} -> {:error, reason}
    end
  end

  defp client_from_opts(opts) when is_list(opts) do
    merged = merge_runtime_options(config(), opts)

    req_options =
      merge_req_options(
        normalize_req_options(Keyword.get(config(), :req_options, [])),
        normalize_req_options(Keyword.get(opts, :req_options, []))
      )

    {:ok,
     new(
       secret_api_key: Keyword.get(merged, :secret_api_key),
       project_id: Keyword.get(merged, :project_id),
       base_url: Keyword.get(merged, :base_url, @default_base_url),
       req_options: req_options
     )}
  end

  defp merge_runtime_options(global_config, opts) do
    known_keys = [:secret_api_key, :project_id, :base_url]

    Enum.reduce(known_keys, global_config, fn key, acc ->
      if Keyword.has_key?(opts, key) do
        Keyword.put(acc, key, Keyword.get(opts, key))
      else
        acc
      end
    end)
  end

  defp revenuecat_secret_api_key(%__MODULE__{} = client) do
    case client.secret_api_key do
      nil -> {:error, {:not_configured, :revenuecat_secret_api_key}}
      "" -> {:error, {:not_configured, :revenuecat_secret_api_key}}
      key -> {:ok, key}
    end
  end

  defp revenuecat_project_id(%__MODULE__{} = client) do
    case client.project_id do
      nil -> {:error, {:not_configured, :revenuecat_project_id}}
      "" -> {:error, {:not_configured, :revenuecat_project_id}}
      project_id -> {:ok, project_id}
    end
  end

  defp config do
    Application.get_env(:revenue_cat, :revenuecat, [])
  end

  defp fetch_active_entitlement_ids(client, secret_api_key, project_id, customer_id) do
    url =
      "/projects/#{URI.encode_www_form(project_id)}/customers/#{URI.encode_www_form(customer_id)}/active_entitlements"
      |> api_url(client)

    collect_paginated_items(
      client,
      secret_api_key,
      url,
      MapSet.new(),
      &collect_active_entitlement_ids/2,
      not_found: :empty_list
    )
  end

  defp fetch_project_entitlements(client, secret_api_key, project_id) do
    url = "/projects/#{URI.encode_www_form(project_id)}/entitlements" |> api_url(client)

    with {:ok, entitlements} <-
           collect_paginated_items(
             client,
             secret_api_key,
             url,
             [],
             &collect_entitlement_descriptors/2,
             not_found: :error
           ) do
      {:ok, Enum.reverse(entitlements)}
    end
  end

  defp collect_paginated_items(client, secret_api_key, url, acc, collector_fun, opts) do
    do_collect_paginated_items(
      client,
      secret_api_key,
      url,
      acc,
      collector_fun,
      opts,
      MapSet.new()
    )
  end

  defp do_collect_paginated_items(
         client,
         secret_api_key,
         url,
         acc,
         collector_fun,
         opts,
         seen_urls
       ) do
    if MapSet.member?(seen_urls, url) do
      {:error, :invalid_response}
    else
      with {:ok, body} <- fetch_page(client, secret_api_key, url, opts),
           {:ok, items, next_page} <- parse_list_response(body),
           {:ok, next_acc} <- collector_fun.(items, acc) do
        case normalize_next_page_url(client, next_page) do
          nil ->
            {:ok, next_acc}

          {:ok, next_url} ->
            do_collect_paginated_items(
              client,
              secret_api_key,
              next_url,
              next_acc,
              collector_fun,
              opts,
              MapSet.put(seen_urls, url)
            )

          {:error, reason} ->
            {:error, reason}
        end
      end
    end
  end

  defp fetch_page(client, secret_api_key, url, opts) do
    headers = [authorization: "Bearer #{secret_api_key}", accept: "application/json"]
    not_found_mode = Keyword.get(opts, :not_found, :error)

    case Req.get(url, req_options(client, headers)) do
      {:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
        {:ok, body}

      {:ok, %Req.Response{status: 200, body: body}} ->
        Logger.warning("RevenueCat API invalid 200 body shape body=#{inspect(body)}")
        {:error, :invalid_response}

      {:ok, %Req.Response{status: 404, body: body}} ->
        handle_404_response(url, body, not_found_mode)

      {:ok, %Req.Response{status: status, body: body}} ->
        Logger.warning("RevenueCat API error status=#{status} body=#{inspect(body)}")
        {:error, :upstream_error}

      {:error, %Req.TransportError{} = error} ->
        Logger.warning("RevenueCat API transport error: #{inspect(error.reason)}")
        {:error, :upstream_unavailable}
    end
  end

  defp handle_404_response(url, _body, :empty_list) do
    Logger.info("RevenueCat API 404 treated as empty list url=#{url}")
    {:ok, %{"object" => "list", "items" => [], "next_page" => nil}}
  end

  defp handle_404_response(url, body, _mode) do
    Logger.warning("RevenueCat API 404 status url=#{url} body=#{inspect(body)}")
    {:error, :upstream_error}
  end

  defp collect_active_entitlement_ids(items, acc) do
    Enum.reduce_while(items, {:ok, acc}, fn item, {:ok, ids} ->
      case item do
        %{"entitlement_id" => entitlement_id}
        when is_binary(entitlement_id) and entitlement_id != "" ->
          {:cont, {:ok, MapSet.put(ids, entitlement_id)}}

        _ ->
          {:halt, {:error, :invalid_response}}
      end
    end)
  end

  defp collect_entitlement_descriptors(items, acc) do
    Enum.reduce_while(items, {:ok, acc}, fn item, {:ok, descriptors} ->
      with {:ok, id} <- fetch_required_string(item, "id") do
        descriptor = %{
          id: id,
          lookup_key: fetch_optional_string(item, "lookup_key"),
          display_name: fetch_optional_string(item, "display_name")
        }

        {:cont, {:ok, [descriptor | descriptors]}}
      else
        _ -> {:halt, {:error, :invalid_response}}
      end
    end)
  end

  defp parse_list_response(%{"object" => "list", "items" => items} = body) when is_list(items) do
    case Map.get(body, "next_page") do
      nil ->
        {:ok, items, nil}

      next_page when is_binary(next_page) and next_page != "" ->
        {:ok, items, next_page}

      _ ->
        {:error, :invalid_response}
    end
  end

  defp parse_list_response(_body), do: {:error, :invalid_response}

  defp resolve_configured_entitlement_id(
         client,
         secret_api_key,
         project_id,
         configured_entitlement,
         active_entitlement_ids
       ) do
    value = String.trim(configured_entitlement)

    cond do
      value == "" ->
        {:error, {:not_configured, :revenuecat_entitlement_id}}

      MapSet.member?(active_entitlement_ids, value) ->
        {:ok, value}

      entitlement_id_format?(value) ->
        {:ok, value}

      true ->
        with {:ok, entitlements} <- fetch_project_entitlements(client, secret_api_key, project_id) do
          find_matching_entitlement_id(entitlements, value)
        end
    end
  end

  defp find_matching_entitlement_id(entitlements, configured_value) do
    normalized = normalize_match_value(configured_value)

    entitlements
    |> Enum.find(fn entitlement ->
      Enum.any?(
        [entitlement.id, entitlement.lookup_key, entitlement.display_name],
        &(normalize_match_value(&1) == normalized)
      )
    end)
    |> case do
      %{id: id} -> {:ok, id}
      nil -> {:error, {:not_configured, :revenuecat_entitlement_id}}
    end
  end

  defp normalize_match_value(value) when is_binary(value),
    do: String.trim(value) |> String.downcase()

  defp normalize_match_value(_value), do: ""

  defp entitlement_id_format?(value) when is_binary(value) do
    String.match?(value, ~r/^entl[a-zA-Z0-9]+$/)
  end

  defp fetch_required_string(map, key) when is_map(map) and is_binary(key) do
    case Map.get(map, key) do
      value when is_binary(value) ->
        case String.trim(value) do
          "" -> {:error, :invalid_response}
          normalized -> {:ok, normalized}
        end

      _ ->
        {:error, :invalid_response}
    end
  end

  defp fetch_optional_string(map, key) when is_map(map) and is_binary(key) do
    case Map.get(map, key) do
      value when is_binary(value) ->
        case String.trim(value) do
          "" -> nil
          normalized -> normalized
        end

      _ ->
        nil
    end
  end

  defp normalize_next_page_url(_client, nil), do: nil

  defp normalize_next_page_url(client, next_page) when is_binary(next_page) do
    trimmed = String.trim(next_page)

    cond do
      trimmed == "" ->
        nil

      String.starts_with?(trimmed, "http://") or String.starts_with?(trimmed, "https://") ->
        if same_origin_absolute_url?(client, trimmed) do
          {:ok, trimmed}
        else
          Logger.warning("RevenueCat API rejected off-origin next_page url=#{inspect(trimmed)}")
          {:error, :invalid_response}
        end

      String.starts_with?(trimmed, "/") ->
        {:ok, base_origin(client) <> trimmed}

      true ->
        {:ok, api_url(client, trimmed)}
    end
  end

  defp api_url(path, %__MODULE__{} = client) when is_binary(path), do: api_url(client, path)

  defp api_url(%__MODULE__{base_url: base_url}, path) when is_binary(path) do
    base = String.trim_trailing(base_url, "/")
    base <> "/" <> String.trim_leading(path, "/")
  end

  defp base_origin(%__MODULE__{base_url: base_url}) do
    uri = URI.parse(base_url)

    if is_binary(uri.scheme) and is_binary(uri.host) do
      host = uri.host |> String.trim() |> String.downcase()
      scheme = uri.scheme |> String.trim() |> String.downcase()

      default_port? =
        is_nil(uri.port) or
          (scheme == "https" and uri.port == 443) or
          (scheme == "http" and uri.port == 80)

      if default_port? do
        "#{scheme}://#{host}"
      else
        "#{scheme}://#{host}:#{uri.port}"
      end
    else
      @api_origin
    end
  end

  defp same_origin_absolute_url?(client, url) when is_binary(url) do
    case URI.parse(url) do
      %URI{scheme: scheme, host: host} = uri when is_binary(scheme) and is_binary(host) ->
        target_scheme = scheme |> String.trim() |> String.downcase()
        target_host = host |> String.trim() |> String.downcase()

        default_port? =
          is_nil(uri.port) or
            (target_scheme == "https" and uri.port == 443) or
            (target_scheme == "http" and uri.port == 80)

        target_origin =
          if default_port? do
            "#{target_scheme}://#{target_host}"
          else
            "#{target_scheme}://#{target_host}:#{uri.port}"
          end

        target_origin == base_origin(client)

      _ ->
        false
    end
  end

  defp req_options(%__MODULE__{req_options: req_options}, headers) do
    existing_headers = req_options |> Keyword.get(:headers, []) |> normalize_headers()
    required_headers = normalize_headers(headers)

    req_options
    |> Keyword.delete(:headers)
    |> Keyword.put(:headers, merge_headers(existing_headers, required_headers))
  end

  defp normalize_nullable_string(nil), do: nil

  defp normalize_nullable_string(value) do
    value
    |> to_string()
    |> String.trim()
    |> case do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp normalize_base_url(nil), do: @default_base_url

  defp normalize_base_url(value) do
    value
    |> to_string()
    |> String.trim()
    |> case do
      "" -> @default_base_url
      trimmed -> String.trim_trailing(trimmed, "/")
    end
  end

  defp normalize_req_options(value) when is_list(value), do: value
  defp normalize_req_options(_), do: []

  defp merge_req_options(default_opts, override_opts)
       when is_list(default_opts) and is_list(override_opts) do
    default_headers = default_opts |> Keyword.get(:headers, []) |> normalize_headers()
    override_headers = override_opts |> Keyword.get(:headers, []) |> normalize_headers()

    merged_headers =
      if override_headers == [] do
        default_headers
      else
        merge_headers(default_headers, override_headers)
      end

    default_opts
    |> Keyword.delete(:headers)
    |> Keyword.merge(Keyword.delete(override_opts, :headers))
    |> maybe_put_headers(merged_headers)
  end

  defp normalize_headers(headers) when is_list(headers) do
    Enum.reduce(headers, [], fn
      {key, value}, acc when is_atom(key) or is_binary(key) ->
        [{normalize_header_key(key), to_string(value)} | acc]

      _, acc ->
        acc
    end)
    |> Enum.reverse()
  end

  defp normalize_headers(_headers), do: []

  defp merge_headers(existing_headers, required_headers) do
    required_keys = MapSet.new(Enum.map(required_headers, fn {key, _value} -> key end))

    existing_headers
    |> Enum.reject(fn {key, _value} -> MapSet.member?(required_keys, key) end)
    |> Kernel.++(required_headers)
  end

  defp maybe_put_headers(opts, []), do: opts
  defp maybe_put_headers(opts, headers), do: Keyword.put(opts, :headers, headers)

  defp normalize_header_key(key) when is_atom(key) do
    key
    |> Atom.to_string()
    |> String.replace("_", "-")
    |> String.downcase()
  end

  defp normalize_header_key(key) when is_binary(key) do
    key
    |> String.trim()
    |> String.downcase()
  end
end