defmodule RevenueCat do @moduledoc """ Minimal RevenueCat client. Exposes `get_customer/1` (cached) and `fetch_customer/1` (remote). """ @type entitlement_map :: map() alias RevenueCat.Offerings @doc """ Return the entitlements map from a customer. Returns an empty map for non-customer inputs. """ @spec entitlements(RevenueCat.Customer.t()) :: map() def entitlements(%RevenueCat.Customer{} = customer) do customer.entitlements || %{} end def entitlements(_), do: %{} @doc """ Return the subscriptions map from a customer. Returns an empty map for non-customer inputs. """ @spec subscriptions(RevenueCat.Customer.t()) :: map() def subscriptions(%RevenueCat.Customer{} = customer) do customer.subscriptions || %{} end def subscriptions(_), do: %{} @doc """ Return true if the customer has the given entitlement id. """ @spec has_entitlement?(RevenueCat.Customer.t(), String.t() | atom()) :: boolean() def has_entitlement?(%RevenueCat.Customer{} = customer, entitlement_id) when is_binary(entitlement_id) and entitlement_id != "" do Map.has_key?(customer.entitlements || %{}, entitlement_id) end def has_entitlement?(%RevenueCat.Customer{} = customer, entitlement_id) when is_atom(entitlement_id) do has_entitlement?(customer, Atom.to_string(entitlement_id)) end def has_entitlement?(_, _), do: false @doc """ Fetch a single entitlement struct by id. """ @spec entitlement(RevenueCat.Customer.t() | map(), String.t() | atom()) :: RevenueCat.Customer.Entitlement.t() | nil def entitlement(%RevenueCat.Customer{} = customer, entitlement_id) do entitlement(customer.entitlements || %{}, entitlement_id) end def entitlement(%{} = entitlements, entitlement_id) when is_atom(entitlement_id) do entitlement(entitlements, Atom.to_string(entitlement_id)) end def entitlement(%{} = entitlements, entitlement_id) when is_binary(entitlement_id) and entitlement_id != "" do Map.get(entitlements, entitlement_id) end def entitlement(_, _), do: nil @doc """ Fetch a single customer attribute struct by name. """ @spec attribute(RevenueCat.Customer.t(), String.t() | atom()) :: RevenueCat.Customer.Attribute.t() | nil def attribute(%RevenueCat.Customer{} = customer, name) do attribute(customer.attributes || %{}, name) end def attribute(%{} = attributes, name) when is_atom(name) do attribute(attributes, Atom.to_string(name)) end def attribute(%{} = attributes, name) when is_binary(name) and name != "" do Map.get(attributes, name) end def attribute(_, _), do: nil @doc """ Fetch a single subscription struct by id. """ @spec subscription(RevenueCat.Customer.t(), String.t() | atom()) :: RevenueCat.Customer.Subscription.t() | nil def subscription(%RevenueCat.Customer{} = customer, subscription_id) do subscription(customer.subscriptions || %{}, subscription_id) end def subscription(%{} = subscriptions, subscription_id) when is_atom(subscription_id) do subscription(subscriptions, Atom.to_string(subscription_id)) end def subscription(%{} = subscriptions, subscription_id) when is_binary(subscription_id) and subscription_id != "" do Map.get(subscriptions, subscription_id) end def subscription(_, _), do: nil @doc """ Return true if the customer has the given subscription id. """ @spec has_subscription?(RevenueCat.Customer.t(), String.t() | atom()) :: boolean() def has_subscription?(%RevenueCat.Customer{} = customer, subscription_id) when is_binary(subscription_id) and subscription_id != "" do Map.has_key?(customer.subscriptions || %{}, subscription_id) end def has_subscription?(%RevenueCat.Customer{} = customer, subscription_id) when is_atom(subscription_id) do has_subscription?(customer, Atom.to_string(subscription_id)) end def has_subscription?(_, _), do: false @doc """ Fetch a single offering struct by id from an Offerings struct. """ @spec offering(RevenueCat.Offerings.t(), String.t() | atom()) :: RevenueCat.Offerings.Offering.t() | nil def offering(%RevenueCat.Offerings{} = offerings, offering_id) when is_atom(offering_id) do offering(offerings, Atom.to_string(offering_id)) end def offering(%RevenueCat.Offerings{} = offerings, offering_id) when is_binary(offering_id) and offering_id != "" do Enum.find(offerings.offerings, fn o -> o.identifier == offering_id end) end def offering(_, _), do: nil @doc """ Fetch the current offering struct from an Offerings struct. """ @spec current_offering(RevenueCat.Offerings.t()) :: RevenueCat.Offerings.Offering.t() | nil def current_offering(%RevenueCat.Offerings{current_offering_id: id} = offerings) when is_binary(id) and id != "" do offering(offerings, id) end def current_offering(_), do: nil @doc """ Get a customer by app user id, using the customer cache when possible. """ @spec get_customer(String.t()) :: {:ok, RevenueCat.Customer.t()} | {:error, term()} def get_customer(app_user_id) when is_binary(app_user_id) and byte_size(app_user_id) > 0 do case RevenueCat.CustomerCache.get(app_user_id) do {:ok, customer} -> {:ok, customer} :miss -> fetch_customer(app_user_id) end end def get_customer(_), do: {:error, :invalid_request} @doc """ Fetch a customer by app user id, bypassing the customer cache. """ @spec fetch_customer(String.t()) :: {:ok, RevenueCat.Customer.t()} | {:error, term()} def fetch_customer(app_user_id) when is_binary(app_user_id) and byte_size(app_user_id) > 0 do with {:ok, body} <- RevenueCat.Client.do_request(:get, "/v1/subscribers/" <> URI.encode(app_user_id)), {:ok, decoded} <- Jason.decode(body), {:ok, customer} <- RevenueCat.Customer.from_response(decoded) do cache_customer(app_user_id, customer) {:ok, customer} else {:error, reason} -> {:error, reason} end end def fetch_customer(_), do: {:error, :invalid_request} @doc """ Update customer attributes and return the updated customer. """ @spec update_customer_attributes(String.t(), map()) :: {:ok, RevenueCat.Customer.t()} | {:error, term()} def update_customer_attributes(app_user_id, attributes) when is_binary(app_user_id) and byte_size(app_user_id) > 0 and is_map(attributes) do path = "/v1/subscribers/" <> URI.encode(app_user_id) <> "/attributes" payload = %{"attributes" => normalize_attributes(attributes)} with {:ok, body} <- RevenueCat.Client.do_request(:post, path, json: payload), {:ok, decoded} <- Jason.decode(body) do case customer_from_update_response(decoded) do {:ok, customer} -> {:ok, customer} {:error, _} -> fetch_customer(app_user_id) end else {:error, %Jason.DecodeError{}} -> fetch_customer(app_user_id) {:error, reason} -> {:error, reason} end end def update_customer_attributes(_, _), do: {:error, :invalid_request} @doc """ Get Offerings for a given `app_user_id`. This endpoint can optionally take a `:platform` (string) which will be sent as the `X-Platform` header (lower-cased to `x-platform`), e.g. `"ios"`. """ @spec fetch_offerings(String.t(), keyword()) :: {:ok, Offerings.t()} | {:error, term()} def fetch_offerings(app_user_id, opts \\ []) def fetch_offerings(app_user_id, opts) when is_binary(app_user_id) and byte_size(app_user_id) > 0 and is_list(opts) do path = "/v1/subscribers/" <> URI.encode(app_user_id) <> "/offerings" with {:ok, headers} <- offerings_headers(opts), {:ok, body} <- RevenueCat.Client.do_request(:get, path, headers: headers), {:ok, decoded} <- Jason.decode(body), {:ok, offerings} <- Offerings.from_response(decoded) do {:ok, offerings} else {:error, reason} -> {:error, reason} end end def fetch_offerings(_, _), do: {:error, :invalid_request} defp customer_from_update_response(%{"value" => value}) when is_map(value) do RevenueCat.Customer.from_response(value) end defp customer_from_update_response(value) when is_map(value) do RevenueCat.Customer.from_response(value) end defp customer_from_update_response(_), do: {:error, :missing_customer} defp cache_customer(app_user_id, %RevenueCat.Customer{} = customer) do ttl_seconds = Application.get_env(:revenuecat, :customer_cache_ttl_seconds, 120) RevenueCat.CustomerCache.put(app_user_id, customer, ttl_seconds) end defp normalize_attributes(attributes) when is_map(attributes) do Map.new(attributes, fn {key, value} -> {key, normalize_attribute_value(value)} end) end defp normalize_attribute_value(%{"value" => _} = value), do: value defp normalize_attribute_value(%{value: _} = value) do Map.new(value, fn {k, v} -> {to_string(k), v} end) end defp normalize_attribute_value(value) when is_binary(value) or is_nil(value) do %{"value" => value} end defp normalize_attribute_value(value) do %{"value" => to_string(value)} end defp offerings_headers(opts) when is_list(opts) do case Keyword.fetch(opts, :platform) do :error -> {:ok, []} {:ok, nil} -> {:ok, []} {:ok, platform} when is_binary(platform) and platform != "" -> {:ok, [{"x-platform", platform}]} {:ok, _} -> {:error, :invalid_request} end end end