Skip to content

Commit 2dd144b

Browse files
authored
Starter tier: Upgrade page remodelling (#5394)
* add a new (feature flagged) upgrade page offering v5 plans * include starter tier plans in available_plans_for + use dev prices in test * upgrade page remodelling with starter tier * mobile optimizations * optimize for darkmode * add embedded dashboards as a growth benefit * do not hide header on LegacyChoosePlan * consistent v5 plan feature order * slight grandfathering notice adjustment * display monthly price too on yearly plans * default to v5 plans unlesss legacy? is true * refactor: suggest volume not plan for emails * align back link with page title * render grandfathering notice for growth v4 too
1 parent 9744961 commit 2dd144b

36 files changed

+3027
-471
lines changed

config/test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ config :plausible, Plausible.ClickhouseRepo,
1717
config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter
1818

1919
config :plausible,
20-
paddle_api: Plausible.PaddleApi.Mock,
20+
paddle_api: Plausible.Billing.TestPaddleApiMock,
2121
google_api: Plausible.Google.API.Mock
2222

2323
config :bamboo, :refute_timeout, 10

lib/plausible/billing/plan.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ defmodule Plausible.Billing.Plan do
1616
# production plans, contain multiple generations of plans in the same file
1717
# for testing purposes.
1818
field :generation, :integer
19-
field :kind, Ecto.Enum, values: [:growth, :business]
19+
field :kind, Ecto.Enum, values: [:starter, :growth, :business]
2020

2121
field :features, Plausible.Billing.Ecto.FeatureList
2222
field :monthly_pageview_limit, :integer

lib/plausible/billing/plans.ex

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Plausible.Billing.Plans do
44
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
55
alias Plausible.Teams
66

7-
@generations [:legacy_plans, :plans_v1, :plans_v2, :plans_v3, :plans_v4]
7+
@generations [:legacy_plans, :plans_v1, :plans_v2, :plans_v3, :plans_v4, :plans_v5]
88

99
for group <- Enum.flat_map(@generations, &[&1, :"sandbox_#{&1}"]) do
1010
path = Application.app_dir(:plausible, ["priv", "#{group}.json"])
@@ -32,41 +32,62 @@ defmodule Plausible.Billing.Plans do
3232
end
3333
end
3434

35-
@spec growth_plans_for(Subscription.t()) :: [Plan.t()]
35+
defp starter_plans_for(legacy?) do
36+
if legacy? do
37+
[]
38+
else
39+
Enum.filter(plans_v5(), &(&1.kind == :starter))
40+
end
41+
end
42+
43+
@spec growth_plans_for(Subscription.t(), boolean()) :: [Plan.t()]
3644
@doc """
3745
Returns a list of growth plans available for the subscription to choose.
3846
3947
As new versions of plans are introduced, subscriptions which were on old plans can
4048
still choose from old plans.
4149
"""
42-
def growth_plans_for(subscription) do
50+
def growth_plans_for(subscription, legacy? \\ false) do
4351
owned_plan = get_regular_plan(subscription)
4452

53+
default_plans = if legacy?, do: plans_v4(), else: plans_v5()
54+
4555
cond do
46-
is_nil(owned_plan) -> plans_v4()
47-
subscription && Subscriptions.expired?(subscription) -> plans_v4()
48-
owned_plan.kind == :business -> plans_v4()
56+
is_nil(owned_plan) -> default_plans
57+
subscription && Subscriptions.expired?(subscription) -> default_plans
58+
owned_plan.kind == :business -> default_plans
4959
owned_plan.generation == 1 -> plans_v1() |> drop_high_plans(owned_plan)
5060
owned_plan.generation == 2 -> plans_v2() |> drop_high_plans(owned_plan)
5161
owned_plan.generation == 3 -> plans_v3()
5262
owned_plan.generation == 4 -> plans_v4()
63+
owned_plan.generation == 5 -> plans_v5()
5364
end
5465
|> Enum.filter(&(&1.kind == :growth))
5566
end
5667

57-
def business_plans_for(subscription) do
68+
def business_plans_for(subscription, legacy? \\ false) do
5869
owned_plan = get_regular_plan(subscription)
5970

71+
default_plans = if legacy?, do: plans_v4(), else: plans_v5()
72+
6073
cond do
61-
subscription && Subscriptions.expired?(subscription) -> plans_v4()
74+
subscription && Subscriptions.expired?(subscription) -> default_plans
6275
owned_plan && owned_plan.generation < 4 -> plans_v3()
63-
true -> plans_v4()
76+
owned_plan && owned_plan.generation < 5 -> plans_v4()
77+
true -> default_plans
6478
end
6579
|> Enum.filter(&(&1.kind == :business))
6680
end
6781

6882
def available_plans_for(subscription, opts \\ []) do
69-
plans = growth_plans_for(subscription) ++ business_plans_for(subscription)
83+
legacy? = Keyword.get(opts, :legacy?, false)
84+
85+
plans =
86+
Enum.concat([
87+
starter_plans_for(legacy?),
88+
growth_plans_for(subscription, legacy?),
89+
business_plans_for(subscription, legacy?)
90+
])
7091

7192
plans =
7293
if Keyword.get(opts, :with_prices) do
@@ -192,42 +213,31 @@ defmodule Plausible.Billing.Plans do
192213
end
193214

194215
@doc """
195-
Returns the most appropriate plan for a team based on its usage during a
196-
given cycle.
216+
Returns the most appropriate monthly pageview volume for a given usage cycle.
217+
The cycle is either last 30 days (for trials) or last billing cycle for teams
218+
with an existing subscription.
197219
198-
If the usage during the cycle exceeds the enterprise-level threshold, or if
199-
the team already has an enterprise plan, it suggests the :enterprise
200-
plan.
220+
The generation and tier from which we're searching for a suitable volume doesn't
221+
matter - the monthly pageview volumes for all plans starting from v3 are going from
222+
10k to 10M. This function uses v4 Growth but it might as well be e.g. v5 Business.
201223
202-
Otherwise, it recommends the plan where the cycle usage falls just under the
203-
plan's limit from the available options for the team.
224+
If the usage during the cycle exceeds the enterprise-level threshold, or if
225+
the team already has an enterprise plan, it returns `:enterprise`. Otherwise,
226+
a string representing the volume, e.g. "100k" or "5M".
204227
"""
205-
@enterprise_level_usage 10_000_000
206-
@spec suggest(Teams.Team.t(), non_neg_integer()) :: Plan.t()
207-
def suggest(team, usage_during_cycle) do
208-
cond do
209-
usage_during_cycle > @enterprise_level_usage ->
210-
:enterprise
211-
212-
Teams.Billing.enterprise_configured?(team) ->
213-
:enterprise
214-
215-
true ->
216-
subscription = Teams.Billing.get_subscription(team)
217-
suggest_by_usage(subscription, usage_during_cycle)
228+
@spec suggest_volume(Teams.Team.t(), non_neg_integer()) :: String.t() | :enterprise
229+
def suggest_volume(team, usage_during_cycle) do
230+
if Teams.Billing.enterprise_configured?(team) do
231+
:enterprise
232+
else
233+
plans_v4()
234+
|> Enum.filter(&(&1.kind == :growth))
235+
|> Enum.find(%{volume: :enterprise}, &(usage_during_cycle < &1.monthly_pageview_limit))
236+
|> Map.get(:volume)
218237
end
219238
end
220239

221-
def suggest_by_usage(subscription, usage_during_cycle) do
222-
available_plans =
223-
if business_tier?(subscription),
224-
do: business_plans_for(subscription),
225-
else: growth_plans_for(subscription)
226-
227-
Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit))
228-
end
229-
230240
def all() do
231-
legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4()
241+
legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4() ++ plans_v5()
232242
end
233243
end

lib/plausible/billing/qouta/quota.ex

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,35 @@ defmodule Plausible.Billing.Quota do
5757
`:custom` is returned. This means that this kind of usage should get on
5858
a custom plan.
5959
60-
To avoid confusion, we do not recommend Growth tiers for customers that
61-
are already on a Business tier (even if their usage would fit Growth).
60+
To avoid confusion, we do not recommend a lower tier for customers that
61+
are already on a higher tier (even if their usage is low enough).
6262
6363
`nil` is returned if the usage is not eligible for upgrade.
6464
"""
65-
def suggest_tier(usage, highest_growth, highest_business, owned_tier) do
65+
def suggest_tier(usage, highest_starter, highest_growth, highest_business, owned_tier) do
66+
cond do
67+
not eligible_for_upgrade?(usage) ->
68+
nil
69+
70+
usage_fits_plan?(usage, highest_starter) and owned_tier not in [:business, :growth] ->
71+
:starter
72+
73+
usage_fits_plan?(usage, highest_growth) and owned_tier != :business ->
74+
:growth
75+
76+
usage_fits_plan?(usage, highest_business) ->
77+
:business
78+
79+
true ->
80+
:custom
81+
end
82+
end
83+
84+
@doc """
85+
[DEPRECATED] Used in LegacyChoosePlan in order to suggest a tier
86+
when `starter_tier` flag is not enabled.
87+
"""
88+
def legacy_suggest_tier(usage, highest_growth, highest_business, owned_tier) do
6689
cond do
6790
not eligible_for_upgrade?(usage) -> nil
6891
usage_fits_plan?(usage, highest_growth) and owned_tier != :business -> :growth

lib/plausible/billing/site_locker.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ defmodule Plausible.Billing.SiteLocker do
5555
defp send_grace_period_end_email(team, true) do
5656
team = Repo.preload(team, [:owners, :billing_members])
5757
usage = Teams.Billing.monthly_pageview_usage(team)
58-
suggested_plan = Plausible.Billing.Plans.suggest(team, usage.last_cycle.total)
58+
suggested_volume = Plausible.Billing.Plans.suggest_volume(team, usage.last_cycle.total)
5959

6060
for recipient <- team.owners ++ team.billing_members do
6161
recipient
62-
|> PlausibleWeb.Email.dashboard_locked(team, usage, suggested_plan)
62+
|> PlausibleWeb.Email.dashboard_locked(team, usage, suggested_volume)
6363
|> Plausible.Mailer.send()
6464
end
6565
end
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
defmodule PlausibleWeb.Components.Billing.LegacyPlanBenefits do
2+
@moduledoc """
3+
[DEPRECATED] This file is essentially a copy of
4+
`PlausibleWeb.Components.Billing.PlanBenefits` with the
5+
intent of keeping the old behaviour in place for the users without
6+
the `starter_tier` feature flag enabled.
7+
"""
8+
9+
use Phoenix.Component
10+
alias Plausible.Billing.Plan
11+
12+
attr :benefits, :list, required: true
13+
attr :class, :string, default: nil
14+
15+
@doc """
16+
This function takes a list of benefits returned by either one of:
17+
18+
* `for_growth/1`
19+
* `for_business/2`
20+
* `for_enterprise/1`.
21+
22+
and renders them as HTML.
23+
24+
The benefits in the given list can be either strings or functions
25+
returning a Phoenix component. This allows, for example, to render
26+
links within the plan benefit text.
27+
"""
28+
def render(assigns) do
29+
~H"""
30+
<ul role="list" class={["mt-8 space-y-3 text-sm leading-6 xl:mt-10", @class]}>
31+
<li :for={benefit <- @benefits} class="flex gap-x-3">
32+
<Heroicons.check class="h-6 w-5 text-indigo-600 dark:text-green-600" />
33+
{if is_binary(benefit), do: benefit, else: benefit.(assigns)}
34+
</li>
35+
</ul>
36+
"""
37+
end
38+
39+
@doc """
40+
This function takes a growth plan and returns a list representing
41+
the different benefits a user gets when subscribing to this plan.
42+
"""
43+
def for_growth(plan) do
44+
[
45+
team_member_limit_benefit(plan),
46+
site_limit_benefit(plan),
47+
data_retention_benefit(plan),
48+
"Intuitive, fast and privacy-friendly dashboard",
49+
"Email/Slack reports",
50+
"Google Analytics import"
51+
]
52+
|> Kernel.++(feature_benefits(plan))
53+
|> Kernel.++(["Saved Segments"])
54+
|> Enum.filter(& &1)
55+
end
56+
57+
@doc """
58+
Returns Business benefits for the given Business plan.
59+
60+
A second argument is also required - list of Growth benefits. This
61+
is because we don't want to list the same benefits in both Growth
62+
and Business. Everything in Growth is also included in Business.
63+
"""
64+
def for_business(plan, growth_benefits) do
65+
[
66+
"Everything in Growth",
67+
team_member_limit_benefit(plan),
68+
site_limit_benefit(plan),
69+
data_retention_benefit(plan)
70+
]
71+
|> Kernel.++(feature_benefits(plan))
72+
|> Kernel.--(growth_benefits)
73+
|> Kernel.++(["Priority support"])
74+
|> Enum.filter(& &1)
75+
end
76+
77+
@doc """
78+
This function only takes a list of business benefits. Since all
79+
limits and features of enterprise plans are configurable, we can
80+
say on the upgrade page that enterprise plans include everything
81+
in Business.
82+
"""
83+
def for_enterprise(business_benefits) do
84+
team_members =
85+
if "Up to 10 team members" in business_benefits, do: "10+ team members"
86+
87+
data_retention =
88+
if "5 years of data retention" in business_benefits, do: "5+ years of data retention"
89+
90+
[
91+
"Everything in Business",
92+
team_members,
93+
"50+ sites",
94+
"600+ Stats API requests per hour",
95+
&sites_api_benefit/1,
96+
data_retention,
97+
"Technical onboarding"
98+
]
99+
|> Enum.filter(& &1)
100+
end
101+
102+
defp data_retention_benefit(%Plan{} = plan) do
103+
if plan.data_retention_in_years, do: "#{plan.data_retention_in_years} years of data retention"
104+
end
105+
106+
defp team_member_limit_benefit(%Plan{} = plan) do
107+
case plan.team_member_limit do
108+
:unlimited -> "Unlimited team members"
109+
number -> "Up to #{number} team members"
110+
end
111+
end
112+
113+
defp site_limit_benefit(%Plan{} = plan), do: "Up to #{plan.site_limit} sites"
114+
115+
defp feature_benefits(%Plan{} = plan) do
116+
Enum.flat_map(plan.features, fn feature_mod ->
117+
case feature_mod.name() do
118+
:goals -> ["Goals and custom events"]
119+
:teams -> []
120+
:shared_links -> []
121+
:stats_api -> ["Stats API (600 requests per hour)", "Looker Studio Connector"]
122+
:revenue_goals -> ["Ecommerce revenue attribution"]
123+
_ -> [feature_mod.display_name()]
124+
end
125+
end)
126+
end
127+
128+
defp sites_api_benefit(assigns) do
129+
~H"""
130+
<p>
131+
Sites API access for
132+
<.link
133+
class="text-indigo-500 hover:text-indigo-400"
134+
href="https://plausible.io/white-label-web-analytics"
135+
>
136+
reselling
137+
</.link>
138+
</p>
139+
"""
140+
end
141+
end

0 commit comments

Comments
 (0)