| 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) |
|
|
| |
| Module.put_attribute(__MODULE__, :external_resource, path) |
| end |
|
|
| |
| |
| 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 |
|
|