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