Skip to content

Starter tier: Upgrade page remodelling #5394

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ config :plausible, Plausible.ClickhouseRepo,
config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter

config :plausible,
paddle_api: Plausible.PaddleApi.Mock,
paddle_api: Plausible.Billing.TestPaddleApiMock,
google_api: Plausible.Google.API.Mock

config :bamboo, :refute_timeout, 10
Expand Down
2 changes: 1 addition & 1 deletion lib/plausible/billing/plan.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule Plausible.Billing.Plan do
# production plans, contain multiple generations of plans in the same file
# for testing purposes.
field :generation, :integer
field :kind, Ecto.Enum, values: [:growth, :business]
field :kind, Ecto.Enum, values: [:starter, :growth, :business]

field :features, Plausible.Billing.Ecto.FeatureList
field :monthly_pageview_limit, :integer
Expand Down
90 changes: 50 additions & 40 deletions lib/plausible/billing/plans.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Plausible.Billing.Plans do
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
alias Plausible.Teams

@generations [:legacy_plans, :plans_v1, :plans_v2, :plans_v3, :plans_v4]
@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"])
Expand Down Expand Up @@ -32,41 +32,62 @@ defmodule Plausible.Billing.Plans do
end
end

@spec growth_plans_for(Subscription.t()) :: [Plan.t()]
defp starter_plans_for(legacy?) do
if legacy? do
[]
else
Enum.filter(plans_v5(), &(&1.kind == :starter))
end
end

@spec growth_plans_for(Subscription.t(), boolean()) :: [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
def growth_plans_for(subscription, legacy? \\ false) do
owned_plan = get_regular_plan(subscription)

default_plans = if legacy?, do: plans_v4(), else: plans_v5()

cond do
is_nil(owned_plan) -> plans_v4()
subscription && Subscriptions.expired?(subscription) -> plans_v4()
owned_plan.kind == :business -> plans_v4()
is_nil(owned_plan) -> default_plans
subscription && Subscriptions.expired?(subscription) -> default_plans
owned_plan.kind == :business -> default_plans
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
def business_plans_for(subscription, legacy? \\ false) do
owned_plan = get_regular_plan(subscription)

default_plans = if legacy?, do: plans_v4(), else: plans_v5()

cond do
subscription && Subscriptions.expired?(subscription) -> plans_v4()
subscription && Subscriptions.expired?(subscription) -> default_plans
owned_plan && owned_plan.generation < 4 -> plans_v3()
true -> plans_v4()
owned_plan && owned_plan.generation < 5 -> plans_v4()
true -> default_plans
end
|> Enum.filter(&(&1.kind == :business))
end

def available_plans_for(subscription, opts \\ []) do
plans = growth_plans_for(subscription) ++ business_plans_for(subscription)
legacy? = Keyword.get(opts, :legacy?, false)

plans =
Enum.concat([
starter_plans_for(legacy?),
growth_plans_for(subscription, legacy?),
business_plans_for(subscription, legacy?)
])

plans =
if Keyword.get(opts, :with_prices) do
Expand Down Expand Up @@ -192,42 +213,31 @@ defmodule Plausible.Billing.Plans do
end

@doc """
Returns the most appropriate plan for a team based on its usage during a
given cycle.
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.

If the usage during the cycle exceeds the enterprise-level threshold, or if
the team already has an enterprise plan, it suggests the :enterprise
plan.
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.

Otherwise, it recommends the plan where the cycle usage falls just under the
plan's limit from the available options for the team.
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".
"""
@enterprise_level_usage 10_000_000
@spec suggest(Teams.Team.t(), non_neg_integer()) :: Plan.t()
def suggest(team, usage_during_cycle) do
cond do
usage_during_cycle > @enterprise_level_usage ->
:enterprise

Teams.Billing.enterprise_configured?(team) ->
:enterprise

true ->
subscription = Teams.Billing.get_subscription(team)
suggest_by_usage(subscription, usage_during_cycle)
@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 suggest_by_usage(subscription, usage_during_cycle) do
available_plans =
if business_tier?(subscription),
do: business_plans_for(subscription),
else: growth_plans_for(subscription)

Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit))
end

def all() do
legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4()
legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4() ++ plans_v5()
end
end
29 changes: 26 additions & 3 deletions lib/plausible/billing/qouta/quota.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,35 @@ defmodule Plausible.Billing.Quota do
`:custom` is returned. This means that this kind of usage should get on
a custom plan.

To avoid confusion, we do not recommend Growth tiers for customers that
are already on a Business tier (even if their usage would fit Growth).
To avoid confusion, we do not recommend a lower tier for customers that
are already on a higher tier (even if their usage is low enough).

`nil` is returned if the usage is not eligible for upgrade.
"""
def suggest_tier(usage, highest_growth, highest_business, owned_tier) do
def suggest_tier(usage, highest_starter, highest_growth, highest_business, owned_tier) do
cond do
not eligible_for_upgrade?(usage) ->
nil

usage_fits_plan?(usage, highest_starter) and owned_tier not in [:business, :growth] ->
:starter

usage_fits_plan?(usage, highest_growth) and owned_tier != :business ->
:growth

usage_fits_plan?(usage, highest_business) ->
:business

true ->
:custom
end
end

@doc """
[DEPRECATED] Used in LegacyChoosePlan in order to suggest a tier
when `starter_tier` flag is not enabled.
"""
def legacy_suggest_tier(usage, highest_growth, highest_business, owned_tier) do
cond do
not eligible_for_upgrade?(usage) -> nil
usage_fits_plan?(usage, highest_growth) and owned_tier != :business -> :growth
Expand Down
4 changes: 2 additions & 2 deletions lib/plausible/billing/site_locker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ defmodule Plausible.Billing.SiteLocker do
defp send_grace_period_end_email(team, true) do
team = Repo.preload(team, [:owners, :billing_members])
usage = Teams.Billing.monthly_pageview_usage(team)
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total)
suggested_volume = Plausible.Billing.Plans.suggest_volume(team, usage.last_cycle.total)

for recipient <- team.owners ++ team.billing_members do
recipient
|> PlausibleWeb.Email.dashboard_locked(team, usage, suggested_plan)
|> PlausibleWeb.Email.dashboard_locked(team, usage, suggested_volume)
|> Plausible.Mailer.send()
end
end
Expand Down
141 changes: 141 additions & 0 deletions lib/plausible_web/components/billing/legacy_plan_benefits.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
defmodule PlausibleWeb.Components.Billing.LegacyPlanBenefits do
@moduledoc """
[DEPRECATED] This file is essentially a copy of
`PlausibleWeb.Components.Billing.PlanBenefits` with the
intent of keeping the old behaviour in place for the users without
the `starter_tier` feature flag enabled.
"""

use Phoenix.Component
alias Plausible.Billing.Plan

attr :benefits, :list, required: true
attr :class, :string, default: nil

@doc """
This function takes a list of benefits returned by either one of:

* `for_growth/1`
* `for_business/2`
* `for_enterprise/1`.

and renders them as HTML.

The benefits in the given list can be either strings or functions
returning a Phoenix component. This allows, for example, to render
links within the plan benefit text.
"""
def render(assigns) do
~H"""
<ul role="list" class={["mt-8 space-y-3 text-sm leading-6 xl:mt-10", @class]}>
<li :for={benefit <- @benefits} class="flex gap-x-3">
<Heroicons.check class="h-6 w-5 text-indigo-600 dark:text-green-600" />
{if is_binary(benefit), do: benefit, else: benefit.(assigns)}
</li>
</ul>
"""
end

@doc """
This function takes a growth plan and returns a list representing
the different benefits a user gets when subscribing to this plan.
"""
def for_growth(plan) do
[
team_member_limit_benefit(plan),
site_limit_benefit(plan),
data_retention_benefit(plan),
"Intuitive, fast and privacy-friendly dashboard",
"Email/Slack reports",
"Google Analytics import"
]
|> Kernel.++(feature_benefits(plan))
|> Kernel.++(["Saved Segments"])
|> Enum.filter(& &1)
end

@doc """
Returns Business benefits for the given Business plan.

A second argument is also required - list of Growth benefits. This
is because we don't want to list the same benefits in both Growth
and Business. Everything in Growth is also included in Business.
"""
def for_business(plan, growth_benefits) do
[
"Everything in Growth",
team_member_limit_benefit(plan),
site_limit_benefit(plan),
data_retention_benefit(plan)
]
|> Kernel.++(feature_benefits(plan))
|> Kernel.--(growth_benefits)
|> Kernel.++(["Priority support"])
|> Enum.filter(& &1)
end

@doc """
This function only takes a list of business benefits. Since all
limits and features of enterprise plans are configurable, we can
say on the upgrade page that enterprise plans include everything
in Business.
"""
def for_enterprise(business_benefits) do
team_members =
if "Up to 10 team members" in business_benefits, do: "10+ team members"

data_retention =
if "5 years of data retention" in business_benefits, do: "5+ years of data retention"

[
"Everything in Business",
team_members,
"50+ sites",
"600+ Stats API requests per hour",
&sites_api_benefit/1,
data_retention,
"Technical onboarding"
]
|> Enum.filter(& &1)
end

defp data_retention_benefit(%Plan{} = plan) do
if plan.data_retention_in_years, do: "#{plan.data_retention_in_years} years of data retention"
end

defp team_member_limit_benefit(%Plan{} = plan) do
case plan.team_member_limit do
:unlimited -> "Unlimited team members"
number -> "Up to #{number} team members"
end
end

defp site_limit_benefit(%Plan{} = plan), do: "Up to #{plan.site_limit} sites"

defp feature_benefits(%Plan{} = plan) do
Enum.flat_map(plan.features, fn feature_mod ->
case feature_mod.name() do
:goals -> ["Goals and custom events"]
:teams -> []
:shared_links -> []
:stats_api -> ["Stats API (600 requests per hour)", "Looker Studio Connector"]
:revenue_goals -> ["Ecommerce revenue attribution"]
_ -> [feature_mod.display_name()]
end
end)
end

defp sites_api_benefit(assigns) do
~H"""
<p>
Sites API access for
<.link
class="text-indigo-500 hover:text-indigo-400"
href="https://plausible.io/white-label-web-analytics"
>
reselling
</.link>
</p>
"""
end
end
Loading
Loading