defmodule Plausible.Billing.Plans do alias Plausible.Billing.Subscriptions use Plausible.Repo alias Plausible.Billing.{Subscription, Plan, EnterprisePlan} alias Plausible.Teams @generations [:legacy_plans, :plans_v1, :plans_v2, :plans_v3, :plans_v4, :plans_v5] for group <- Enum.flat_map(@generations, &[&1, :"sandbox_#{&1}"]) do path = Application.app_dir(:plausible, ["priv", "#{group}.json"]) plans_list = for attrs <- path |> File.read!() |> Jason.decode!() do %Plan{} |> Plan.changeset(attrs) |> Ecto.Changeset.apply_action!(nil) end Module.put_attribute(__MODULE__, group, plans_list) # https://hexdocs.pm/elixir/1.15/Module.html#module-external_resource Module.put_attribute(__MODULE__, :external_resource, path) end # Generate functions returning a specific generation of plans depending on # the app environment for fn_name <- @generations do defp unquote(fn_name)() do if Application.get_env(:plausible, :environment) == "staging" do unquote(Macro.escape(Module.get_attribute(__MODULE__, :"sandbox_#{fn_name}"))) else unquote(Macro.escape(Module.get_attribute(__MODULE__, fn_name))) end end end defp starter_plans_for(subscription) do active_plan = get_regular_plan(subscription, only_non_expired: true) case active_plan do %Plan{kind: :growth, generation: g} when g <= 4 -> [] _ -> Enum.filter(plans_v5(), &(&1.kind == :starter)) end end @spec growth_plans_for(Subscription.t()) :: [Plan.t()] @doc """ Returns a list of growth plans available for the subscription to choose. As new versions of plans are introduced, subscriptions which were on old plans can still choose from old plans. """ def growth_plans_for(subscription) do owned_plan = get_regular_plan(subscription) latest = plans_v5() cond do is_nil(owned_plan) -> latest subscription && Subscriptions.expired?(subscription) -> latest owned_plan.kind == :business -> latest owned_plan.generation == 1 -> plans_v1() |> drop_high_plans(owned_plan) owned_plan.generation == 2 -> plans_v2() |> drop_high_plans(owned_plan) owned_plan.generation == 3 -> plans_v3() owned_plan.generation == 4 -> plans_v4() owned_plan.generation == 5 -> plans_v5() end |> Enum.filter(&(&1.kind == :growth)) end def business_plans_for(subscription) do owned_plan = get_regular_plan(subscription) latest = plans_v5() cond do subscription && Subscriptions.expired?(subscription) -> latest owned_plan && owned_plan.generation <= 3 -> plans_v3() owned_plan && owned_plan.generation <= 4 -> plans_v4() true -> latest end |> Enum.filter(&(&1.kind == :business)) end def available_plans_for(subscription, opts \\ []) do %{ starter: starter_plans_for(subscription) |> maybe_add_prices(opts), growth: growth_plans_for(subscription) |> maybe_add_prices(opts), business: business_plans_for(subscription) |> maybe_add_prices(opts) } end defp maybe_add_prices([] = _plans, _opts), do: [] defp maybe_add_prices(plans, opts) do if Keyword.get(opts, :with_prices) do customer_ip = Keyword.fetch!(opts, :customer_ip) with_prices(plans, customer_ip) else plans end end @high_legacy_volumes [20_000_000, 50_000_000] defp drop_high_plans(plans, %Plan{monthly_pageview_limit: current_volume} = _owned) do plans |> Enum.reject(fn %Plan{monthly_pageview_limit: plan_volume} -> plan_volume in @high_legacy_volumes and current_volume < plan_volume end) 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 def find(nil), do: nil def find(product_id) do Enum.find(all(), fn plan -> product_id in [plan.monthly_product_id, plan.yearly_product_id] end) end @spec get_subscription_plan(nil | Subscription.t()) :: nil | :free_10k | Plan.t() | EnterprisePlan.t() 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, customer_ip \\ "127.0.0.1") do product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id]) case Plausible.Billing.paddle_api().fetch_prices(product_ids, customer_ip) 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}, customer_ip) do case Plausible.Billing.paddle_api().fetch_prices([product_id], customer_ip) 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, team_id: subscription.team_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 @doc """ Returns the most appropriate monthly pageview volume for a given usage cycle. The cycle is either last 30 days (for trials) or last billing cycle for teams with an existing subscription. The generation and tier from which we're searching for a suitable volume doesn't matter - the monthly pageview volumes for all plans starting from v3 are going from 10k to 10M. This function uses v4 Growth but it might as well be e.g. v5 Business. If the usage during the cycle exceeds the enterprise-level threshold, or if the team already has an enterprise plan, it returns `:enterprise`. Otherwise, a string representing the volume, e.g. "100k" or "5M". """ @spec suggest_volume(Teams.Team.t(), non_neg_integer()) :: String.t() | :enterprise def suggest_volume(team, usage_during_cycle) do if Teams.Billing.enterprise_configured?(team) do :enterprise else plans_v4() |> Enum.filter(&(&1.kind == :growth)) |> Enum.find(%{volume: :enterprise}, &(usage_during_cycle < &1.monthly_pageview_limit)) |> Map.get(:volume) end end def all() do legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4() ++ plans_v5() end end