analytics/lib/plausible/billing/plans.ex

223 lines
6.4 KiB
Elixir

defmodule Plausible.Billing.Plans do
alias Plausible.Billing.Subscriptions
use Plausible.Repo
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
alias Plausible.Auth.User
for f <- [
:legacy_plans,
:plans_v1,
:plans_v2,
:plans_v3,
:plans_v4,
:sandbox_plans
] do
path = Application.app_dir(:plausible, ["priv", "#{f}.json"])
plans_list =
path
|> File.read!()
|> Jason.decode!(keys: :atoms!)
|> Enum.map(&Plan.build!(&1, f))
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 = get_regular_plan(user.subscription)
cond do
Application.get_env(:plausible, :environment) == "dev" -> @sandbox_plans
!owned_plan -> if v4_available, do: @plans_v4, else: @plans_v3
owned_plan.kind == :business -> @plans_v4
owned_plan.generation == 1 -> @plans_v1
owned_plan.generation == 2 -> @plans_v2
owned_plan.generation == 3 -> @plans_v3
owned_plan.generation == 4 -> @plans_v4
end
|> Enum.filter(&(&1.kind == :growth))
end
def business_plans_for(%User{} = user) do
user = Plausible.Users.with_subscription(user)
owned_plan = get_regular_plan(user.subscription)
cond do
Application.get_env(:plausible, :environment) == "dev" -> @sandbox_plans
owned_plan && owned_plan.generation < 4 -> @plans_v3
true -> @plans_v4
end
|> Enum.filter(&(&1.kind == :business))
end
def available_plans_with_prices(%User{} = user) do
(growth_plans_for(user) ++ business_plans_for(user))
|> 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(nil), do: nil
defp find(product_id) do
Enum.find(all(), 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 latest_enterprise_plan_with_price(user) do
enterprise_plan =
Repo.one!(
from(e in EnterprisePlan,
where: e.user_id == ^user.id,
order_by: [desc: e.inserted_at],
limit: 1
)
)
{enterprise_plan, get_price_for(enterprise_plan)}
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
def get_price_for(%EnterprisePlan{paddle_plan_id: product_id}) do
case Plausible.Billing.paddle_api().fetch_prices([product_id]) do
{:ok, prices} -> Map.fetch!(prices, product_id)
{:error, :api_error} -> nil
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_configured?(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_for(user),
else: growth_plans_for(user)
Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit))
end
defp all() do
@legacy_plans ++ @plans_v1 ++ @plans_v2 ++ @plans_v3 ++ @plans_v4
end
end