analytics/lib/plausible/billing/plans.ex

249 lines
7.2 KiB
Elixir

defmodule Plausible.Billing.Plan do
@moduledoc false
@derive Jason.Encoder
@enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_product_id yearly_product_id)a
defstruct @enforce_keys ++ [:monthly_cost, :yearly_cost]
@type t() ::
%__MODULE__{
kind: atom(),
monthly_pageview_limit: non_neg_integer(),
site_limit: non_neg_integer(),
team_member_limit: non_neg_integer() | :unlimited,
volume: String.t(),
monthly_cost: Money.t() | nil,
yearly_cost: Money.t() | nil,
monthly_product_id: String.t() | nil,
yearly_product_id: String.t() | nil
}
| :enterprise
def new(params) when is_map(params) do
struct!(__MODULE__, params)
end
end
defmodule Plausible.Billing.Plans do
alias Plausible.Billing.Subscriptions
use Plausible.Repo
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
alias Plausible.Auth.User
for f <- [
:plans_v1,
:plans_v2,
:plans_v3,
:plans_v4,
:unlisted_plans_v1,
:unlisted_plans_v2,
:sandbox_plans
] do
path = Application.app_dir(:plausible, ["priv", "#{f}.json"])
plans_list =
path
|> File.read!()
|> Jason.decode!(keys: :atoms!)
|> Enum.map(fn raw ->
team_member_limit =
case raw.team_member_limit do
number when is_integer(number) -> number
"unlimited" -> :unlimited
_any -> raise ArgumentError, "Failed to parse team member limit from plan JSON files"
end
volume = PlausibleWeb.StatsView.large_number_format(raw.monthly_pageview_limit)
raw
|> Map.put(:volume, volume)
|> Map.put(:kind, String.to_atom(raw.kind))
|> Map.put(:team_member_limit, team_member_limit)
|> Plan.new()
end)
Module.put_attribute(__MODULE__, f, plans_list)
# https://hexdocs.pm/elixir/1.15/Module.html#module-external_resource
Module.put_attribute(__MODULE__, :external_resource, path)
end
@spec growth_plans_for(User.t()) :: [Plan.t()]
@doc """
Returns a list of growth plans available for the user to choose.
As new versions of plans are introduced, users who were on old plans can
still choose from old plans.
"""
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def growth_plans_for(%User{} = user) do
user = Plausible.Users.with_subscription(user)
v4_available = FunWithFlags.enabled?(:business_tier, for: user)
owned_plan_id = user.subscription && user.subscription.paddle_plan_id
cond do
find(owned_plan_id, @plans_v1) -> @plans_v1
find(owned_plan_id, @plans_v2) -> @plans_v2
find(owned_plan_id, @plans_v3) -> @plans_v3
find(owned_plan_id, plans_sandbox()) -> plans_sandbox()
Application.get_env(:plausible, :environment) == "dev" -> plans_sandbox()
Timex.before?(user.inserted_at, ~D[2022-01-01]) -> @plans_v2
v4_available -> Enum.filter(@plans_v4, &(&1.kind == :growth))
true -> @plans_v3
end
end
def business_plans() do
Enum.filter(@plans_v4, &(&1.kind == :business))
end
def available_plans_with_prices(%User{} = user) do
(growth_plans_for(user) ++ business_plans())
|> with_prices()
|> Enum.group_by(& &1.kind)
end
@spec yearly_product_ids() :: [String.t()]
@doc """
List yearly plans product IDs.
"""
def yearly_product_ids do
for %{yearly_product_id: yearly_product_id} <- all(),
is_binary(yearly_product_id),
do: yearly_product_id
end
defp find(product_id, scope \\ all())
defp find(nil, _scope), do: nil
defp find(product_id, scope) do
Enum.find(scope, fn plan ->
product_id in [plan.monthly_product_id, plan.yearly_product_id]
end)
end
def get_subscription_plan(nil), do: nil
def get_subscription_plan(subscription) do
if subscription.paddle_plan_id == "free_10k" do
:free_10k
else
get_regular_plan(subscription) || get_enterprise_plan(subscription)
end
end
def subscription_interval(subscription) do
case get_subscription_plan(subscription) do
%EnterprisePlan{billing_interval: interval} ->
interval
%Plan{} = plan ->
if plan.monthly_product_id == subscription.paddle_plan_id do
"monthly"
else
"yearly"
end
_any ->
"N/A"
end
end
@doc """
This function takes a list of plans as an argument, gathers all product
IDs in a single list, and makes an API call to Paddle. After a successful
response, fills in the `monthly_cost` and `yearly_cost` fields for each
given plan and returns the new list of plans with completed information.
"""
def with_prices([_ | _] = plans) do
product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id])
case Plausible.Billing.paddle_api().fetch_prices(product_ids) do
{:ok, prices} ->
Enum.map(plans, fn plan ->
plan
|> Map.put(:monthly_cost, prices[plan.monthly_product_id])
|> Map.put(:yearly_cost, prices[plan.yearly_product_id])
end)
{:error, :api_error} ->
plans
end
end
def get_regular_plan(subscription, opts \\ [])
def get_regular_plan(nil, _opts), do: nil
def get_regular_plan(%Subscription{} = subscription, opts) do
if Keyword.get(opts, :only_non_expired) && Subscriptions.expired?(subscription) do
nil
else
find(subscription.paddle_plan_id)
end
end
defp get_enterprise_plan(%Subscription{} = subscription) do
Repo.get_by(EnterprisePlan,
user_id: subscription.user_id,
paddle_plan_id: subscription.paddle_plan_id
)
end
def business_tier?(nil), do: false
def business_tier?(%Subscription{} = subscription) do
case get_subscription_plan(subscription) do
%Plan{kind: :business} -> true
_ -> false
end
end
@enterprise_level_usage 10_000_000
@spec suggest(User.t(), non_neg_integer()) :: Plan.t()
@doc """
Returns the most appropriate plan for a user based on their usage during a
given cycle.
If the usage during the cycle exceeds the enterprise-level threshold, or if
the user already belongs to an enterprise plan, it suggests the :enterprise
plan.
Otherwise, it recommends the plan where the cycle usage falls just under the
plan's limit from the available options for the user.
"""
def suggest(user, usage_during_cycle) do
cond do
usage_during_cycle > @enterprise_level_usage -> :enterprise
Plausible.Auth.enterprise?(user) -> :enterprise
true -> suggest_by_usage(user, usage_during_cycle)
end
end
defp suggest_by_usage(user, usage_during_cycle) do
user = Plausible.Users.with_subscription(user)
available_plans =
if business_tier?(user.subscription),
do: business_plans(),
else: growth_plans_for(user)
Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit))
end
defp all() do
@plans_v1 ++
@unlisted_plans_v1 ++
@plans_v2 ++ @unlisted_plans_v2 ++ @plans_v3 ++ @plans_v4 ++ plans_sandbox()
end
defp plans_sandbox() do
case Application.get_env(:plausible, :environment) do
"dev" -> @sandbox_plans
_ -> []
end
end
end