From 2ada3d700fa694ed9c2eecd91f8bf16e9b86cb8f Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Mon, 23 Oct 2023 19:42:00 +0300 Subject: [PATCH] List plan benefits on the new upgrade page (#3444) * change team member limits for new v4 plans * duplicate business plans with unlimited team members We need to do this because we want grandfathered users to have unlimited team members on business plans as well. Otherwise we'd have to build overrides on the subscription level when checking the limit. * refactor generating plan structs * move Plan module into a separate file * remove not needed conditions * add generation field to plans * sync the sanbox plan limits and features with plan generations * implement displaying plan benefits * add grandfathering notice * plug in the real v3 business plan IDs * optimize N/A text color for darkmode * use String.to_existing_atom instead Co-authored-by: Vini Brasil * Remove the unnecessary part of a comment Co-authored-by: Vini Brasil * make the Plan.new function simpler * use exlamation marks --------- Co-authored-by: Vini Brasil --- lib/plausible/billing/plan.ex | 95 ++++++++++ lib/plausible/billing/plans.ex | 90 +++------ lib/plausible_web/live/choose_plan.ex | 183 ++++++++++++++----- priv/plans_v1.json | 10 + priv/plans_v2.json | 10 + priv/plans_v3.json | 88 +++++++++ priv/plans_v4.json | 48 +++-- priv/sandbox_plans.json | 64 ++++--- priv/unlisted_plans_v1.json | 1 + priv/unlisted_plans_v2.json | 1 + test/plausible/billing/plans_test.exs | 53 ++++-- test/plausible/billing/quota_test.exs | 12 +- test/plausible_web/live/choose_plan_test.exs | 169 ++++++++++++++++- 13 files changed, 639 insertions(+), 185 deletions(-) create mode 100644 lib/plausible/billing/plan.ex diff --git a/lib/plausible/billing/plan.ex b/lib/plausible/billing/plan.ex new file mode 100644 index 0000000000..6f939dfde1 --- /dev/null +++ b/lib/plausible/billing/plan.ex @@ -0,0 +1,95 @@ +defmodule Plausible.Billing.Plan do + @moduledoc false + + @derive Jason.Encoder + @enforce_keys ~w(kind generation site_limit monthly_pageview_limit team_member_limit features volume monthly_product_id yearly_product_id)a + defstruct @enforce_keys ++ [:monthly_cost, :yearly_cost] + + @type t() :: + %__MODULE__{ + kind: atom(), + generation: non_neg_integer(), + monthly_pageview_limit: non_neg_integer(), + site_limit: non_neg_integer(), + team_member_limit: non_neg_integer() | :unlimited, + volume: String.t(), + monthly_cost: Money.t() | nil, + yearly_cost: Money.t() | nil, + monthly_product_id: String.t() | nil, + yearly_product_id: String.t() | nil, + features: [atom()] + } + | :enterprise + + def build!(raw_params, file_name) when is_map(raw_params) do + raw_params + |> put_kind() + |> put_generation() + |> put_volume() + |> put_team_member_limit!(file_name) + |> put_features!(file_name) + |> new!() + end + + defp new!(params) do + struct!(__MODULE__, params) + end + + defp put_kind(params) do + Map.put(params, :kind, String.to_existing_atom(params.kind)) + end + + # Due to grandfathering, we sometimes need to check the "generation" + # (e.g. v1, v2, etc...) of a user's subscription plan. For instance, + # on prod, the users subscribed to a v2 plan are only supposed to + # see v2 plans when they go to the upgrade page. + # + # In the `dev` environment though, "sandbox" plans are used, which + # unlike production plans, contain multiple generations of plans in + # the same file for testing purposes. + defp put_generation(params) do + Map.put(params, :generation, params.generation) + end + + defp put_volume(params) do + volume = + params.monthly_pageview_limit + |> PlausibleWeb.StatsView.large_number_format() + + Map.put(params, :volume, volume) + end + + defp put_team_member_limit!(params, file_name) do + team_member_limit = + case params.team_member_limit do + number when is_integer(number) -> + number + + "unlimited" -> + :unlimited + + other -> + raise ArgumentError, + "Failed to parse team member limit #{inspect(other)} from #{file_name}.json" + end + + Map.put(params, :team_member_limit, team_member_limit) + end + + defp put_features!(params, file_name) do + features = + Plausible.Billing.Feature.list() + |> Enum.filter(fn module -> + to_string(module.name()) in params.features + end) + + if length(features) == length(params.features) do + Map.put(params, :features, features) + else + raise( + ArgumentError, + "Unrecognized feature(s) in #{inspect(params.features)} (#{file_name}.json)" + ) + end + end +end diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 2566fe772e..93d2c84725 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -1,30 +1,3 @@ -defmodule Plausible.Billing.Plan do - @moduledoc false - - @derive Jason.Encoder - @enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit features volume monthly_product_id yearly_product_id)a - defstruct @enforce_keys ++ [:monthly_cost, :yearly_cost] - - @type t() :: - %__MODULE__{ - kind: atom(), - monthly_pageview_limit: non_neg_integer(), - site_limit: non_neg_integer(), - team_member_limit: non_neg_integer() | :unlimited, - volume: String.t(), - monthly_cost: Money.t() | nil, - yearly_cost: Money.t() | nil, - monthly_product_id: String.t() | nil, - yearly_product_id: String.t() | nil, - features: [atom()] - } - | :enterprise - - def new(params) when is_map(params) do - struct!(__MODULE__, params) - end -end - defmodule Plausible.Billing.Plans do alias Plausible.Billing.Subscriptions use Plausible.Repo @@ -46,36 +19,7 @@ defmodule Plausible.Billing.Plans do path |> File.read!() |> Jason.decode!(keys: :atoms!) - |> Enum.map(fn raw -> - team_member_limit = - case raw.team_member_limit do - number when is_integer(number) -> number - "unlimited" -> :unlimited - _any -> raise ArgumentError, "Failed to parse team member limit from plan JSON files" - end - - features = - Plausible.Billing.Feature.list() - |> Enum.filter(fn module -> - to_string(module.name()) in raw.features - end) - - if length(features) != length(raw.features), - do: - raise( - ArgumentError, - "Unrecognized feature(s) in #{inspect(raw.features)} (#{f}.json)" - ) - - volume = PlausibleWeb.StatsView.large_number_format(raw.monthly_pageview_limit) - - raw - |> Map.put(:volume, volume) - |> Map.put(:kind, String.to_atom(raw.kind)) - |> Map.put(:team_member_limit, team_member_limit) - |> Map.put(:features, features) - |> Plan.new() - end) + |> Enum.map(&Plan.build!(&1, f)) Module.put_attribute(__MODULE__, f, plans_list) @@ -94,26 +38,34 @@ defmodule Plausible.Billing.Plans do def growth_plans_for(%User{} = user) do user = Plausible.Users.with_subscription(user) v4_available = FunWithFlags.enabled?(:business_tier, for: user) - owned_plan_id = user.subscription && user.subscription.paddle_plan_id + owned_plan = get_regular_plan(user.subscription) cond do - find(owned_plan_id, @plans_v1) -> @plans_v1 - find(owned_plan_id, @plans_v2) -> @plans_v2 - find(owned_plan_id, @plans_v3) -> @plans_v3 - find(owned_plan_id, plans_sandbox()) -> plans_sandbox() Application.get_env(:plausible, :environment) == "dev" -> plans_sandbox() - Timex.before?(user.inserted_at, ~D[2022-01-01]) -> @plans_v2 - v4_available -> Enum.filter(@plans_v4, &(&1.kind == :growth)) - true -> @plans_v3 + !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() do - Enum.filter(@plans_v4, &(&1.kind == :business)) + 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" -> plans_sandbox() + 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()) + (growth_plans_for(user) ++ business_plans_for(user)) |> with_prices() |> Enum.group_by(& &1.kind) end @@ -261,7 +213,7 @@ defmodule Plausible.Billing.Plans do available_plans = if business_tier?(user.subscription), - do: business_plans(), + do: business_plans_for(user), else: growth_plans_for(user) Enum.find(available_plans, &(usage_during_cycle < &1.monthly_pageview_limit)) diff --git a/lib/plausible_web/live/choose_plan.ex b/lib/plausible_web/live/choose_plan.ex index b24887e31b..827de3c0eb 100644 --- a/lib/plausible_web/live/choose_plan.ex +++ b/lib/plausible_web/live/choose_plan.ex @@ -64,6 +64,24 @@ defmodule PlausibleWeb.Live.ChoosePlan do end def render(assigns) do + growth_plan_to_render = + assigns.selected_growth_plan || List.last(assigns.available_plans.growth) + + business_plan_to_render = + assigns.selected_business_plan || List.last(assigns.available_plans.business) + + growth_benefits = growth_benefits(growth_plan_to_render) + + business_benefits = business_benefits(business_plan_to_render, growth_benefits) + + assigns = + assigns + |> assign(:growth_plan_to_render, growth_plan_to_render) + |> assign(:business_plan_to_render, business_plan_to_render) + |> assign(:growth_benefits, growth_benefits) + |> assign(:business_benefits, business_benefits) + |> assign(:enterprise_benefits, enterprise_benefits(business_benefits)) + ~H"""
@@ -84,26 +102,20 @@ defmodule PlausibleWeb.Live.ChoosePlan do <.plan_box kind={:growth} owned={@owned_plan && Map.get(@owned_plan, :kind) == :growth} - plan_to_render={ - if @selected_growth_plan, - do: @selected_growth_plan, - else: List.last(@available_plans.growth) - } + plan_to_render={@growth_plan_to_render} + benefits={@growth_benefits} available={!!@selected_growth_plan} {assigns} /> <.plan_box kind={:business} owned={@owned_plan && Map.get(@owned_plan, :kind) == :business} - plan_to_render={ - if @selected_business_plan, - do: @selected_business_plan, - else: List.last(@available_plans.business) - } + plan_to_render={@business_plan_to_render} + benefits={@business_benefits} available={!!@selected_business_plan} {assigns} /> - <.enterprise_plan_box /> + <.enterprise_plan_box benefits={@enterprise_benefits} />

<.usage usage={@usage} /> @@ -275,30 +287,31 @@ defmodule PlausibleWeb.Live.ChoosePlan do <% end %>

-
    -
  • - <.check_icon class="text-indigo-600 dark:text-green-600" /> 5 products -
  • -
  • - <.check_icon class="text-indigo-600 dark:text-green-600" /> Up to 1,000 subscribers -
  • -
  • - <.check_icon class="text-indigo-600 dark:text-green-600" /> Basic analytics -
  • -
  • - <.check_icon class="text-indigo-600 dark:text-green-600" /> 48-hour support response time -
  • -
+ <%= if @kind == :growth && @plan_to_render.generation < 4 do %> + <.growth_grandfathering_notice /> + <% else %> +
    + <.plan_benefit :for={benefit <- @benefits}><%= benefit %> +
+ <% end %> """ end + defp growth_grandfathering_notice(assigns) do + ~H""" +
    + Your subscription has been grandfathered in at the same rate and terms as when you first joined. If you don't need the "Business" features, you're welcome to stay on this plan. You can adjust the pageview limit or change the billing frequency of this grandfathered plan. If you're interested in business features, you can upgrade to the new "Business" plan. +
+ """ + end + def render_price_info(%{available: false} = assigns) do ~H""" -

+

Custom @@ -356,6 +369,18 @@ defmodule PlausibleWeb.Live.ChoosePlan do """ end + slot :inner_block, required: true + attr :icon_color, :string, default: "indigo-600" + + defp plan_benefit(assigns) do + ~H""" +

  • + <.check_icon class={"text-#{@icon_color} dark:text-green-600"} /> + <%= render_slot(@inner_block) %> +
  • + """ + end + defp contact_button(assigns) do ~H""" <.link @@ -372,7 +397,10 @@ defmodule PlausibleWeb.Live.ChoosePlan do defp enterprise_plan_box(assigns) do ~H""" -
    +

    Enterprise

    @@ -385,25 +413,9 @@ defmodule PlausibleWeb.Live.ChoosePlan do role="list" class="mt-8 space-y-3 text-sm leading-6 xl:mt-10 text-gray-300 dark:text-gray-100" > -

  • - <.check_icon class="text-white dark:text-green-600" /> Unlimited products -
  • -
  • - <.check_icon class="text-white dark:text-green-600" /> Unlimited subscribers -
  • -
  • - <.check_icon class="text-white dark:text-green-600" /> Advanced analytics -
  • -
  • - <.check_icon class="text-white dark:text-green-600" /> - 1-hour, dedicated support response time -
  • -
  • - <.check_icon class="text-white dark:text-green-600" /> Marketing automations -
  • -
  • - <.check_icon class="text-white dark:text-green-600" /> Custom reporting tools -
  • + <.plan_benefit :for={benefit <- @benefits}> + <%= if is_binary(benefit), do: benefit, else: benefit.(assigns) %> +
    """ @@ -471,7 +483,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do ~H""" - + N/A """ @@ -620,6 +632,77 @@ defmodule PlausibleWeb.Live.ChoosePlan do PlausibleWeb.StatsView.large_number_format(volume) end + defp growth_benefits(plan) do + [ + team_member_limit_benefit(plan), + site_limit_benefit(plan), + "Intuitive, fast and privacy-friendly dashboard", + "Email/Slack reports", + "Google Analytics import" + ] + |> Kernel.++(feature_benefits(plan)) + end + + defp business_benefits(plan, growth_benefits) do + [ + "Everything in Growth", + team_member_limit_benefit(plan), + site_limit_benefit(plan) + ] + |> Kernel.++(feature_benefits(plan)) + |> Kernel.--(growth_benefits) + |> Kernel.++(["Priority support"]) + end + + defp enterprise_benefits(business_benefits) do + team_members = + if "Up to 10 team members" in business_benefits, + do: "10+ team members", + else: nil + + [ + "Everything in Business", + team_members, + "50+ sites", + &sites_api_benefit/1, + "Technical onboarding" + ] + |> Enum.filter(& &1) + 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.map(plan.features, fn feature_mod -> + case feature_mod.name() do + :goals -> "Goals and custom events" + :revenue_goals -> "Ecommerce revenue attribution" + _ -> feature_mod.display_name() + end + end) + end + + defp sites_api_benefit(assigns) do + ~H""" +

    + Sites API access for + <.link + class="text-indigo-500 hover:text-indigo-400" + href="https://plausible.io/white-label-web-analytics" + > + reselling + +

    + """ + end + defp contact_link(), do: @contact_link defp billing_faq_link(), do: @billing_faq_link diff --git a/priv/plans_v1.json b/priv/plans_v1.json index d337280f0d..0f38a2ee03 100644 --- a/priv/plans_v1.json +++ b/priv/plans_v1.json @@ -1,6 +1,7 @@ [ { "kind":"growth", + "generation":1, "monthly_pageview_limit":10000, "monthly_product_id":"558018", "yearly_product_id":"572810", @@ -10,6 +11,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":100000, "monthly_product_id":"558745", "yearly_product_id":"590752", @@ -19,6 +21,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":200000, "monthly_product_id":"597485", "yearly_product_id":"597486", @@ -28,6 +31,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":500000, "monthly_product_id":"597487", "yearly_product_id":"597488", @@ -37,6 +41,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":1000000, "monthly_product_id":"597642", "yearly_product_id":"597643", @@ -46,6 +51,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":2000000, "monthly_product_id":"597309", "yearly_product_id":"597310", @@ -55,6 +61,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":5000000, "monthly_product_id":"597311", "yearly_product_id":"597312", @@ -64,6 +71,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":10000000, "monthly_product_id":"642352", "yearly_product_id":"642354", @@ -73,6 +81,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":20000000, "monthly_product_id":"642355", "yearly_product_id":"642356", @@ -82,6 +91,7 @@ }, { "kind":"growth", + "generation":1, "monthly_pageview_limit":50000000, "monthly_product_id":"650652", "yearly_product_id":"650653", diff --git a/priv/plans_v2.json b/priv/plans_v2.json index 265fcfafb4..c1f8d4729f 100644 --- a/priv/plans_v2.json +++ b/priv/plans_v2.json @@ -1,6 +1,7 @@ [ { "kind":"growth", + "generation":2, "monthly_pageview_limit":10000, "monthly_product_id":"654177", "yearly_product_id":"653232", @@ -10,6 +11,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":100000, "monthly_product_id":"654178", "yearly_product_id":"653234", @@ -19,6 +21,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":200000, "monthly_product_id":"653237", "yearly_product_id":"653236", @@ -28,6 +31,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":500000, "monthly_product_id":"653238", "yearly_product_id":"653239", @@ -37,6 +41,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":1000000, "monthly_product_id":"653240", "yearly_product_id":"653242", @@ -46,6 +51,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":2000000, "monthly_product_id":"653253", "yearly_product_id":"653254", @@ -55,6 +61,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":5000000, "monthly_product_id":"653255", "yearly_product_id":"653256", @@ -64,6 +71,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":10000000, "monthly_product_id":"654181", "yearly_product_id":"653257", @@ -73,6 +81,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":20000000, "monthly_product_id":"654182", "yearly_product_id":"653258", @@ -82,6 +91,7 @@ }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":50000000, "monthly_product_id":"654183", "yearly_product_id":"653259", diff --git a/priv/plans_v3.json b/priv/plans_v3.json index d80e878eb9..e0d2485732 100644 --- a/priv/plans_v3.json +++ b/priv/plans_v3.json @@ -1,6 +1,7 @@ [ { "kind":"growth", + "generation":3, "monthly_pageview_limit":10000, "monthly_product_id":"749342", "yearly_product_id":"749343", @@ -10,6 +11,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":100000, "monthly_product_id":"749344", "yearly_product_id":"749345", @@ -19,6 +21,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":200000, "monthly_product_id":"749346", "yearly_product_id":"749347", @@ -28,6 +31,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":500000, "monthly_product_id":"749348", "yearly_product_id":"749349", @@ -37,6 +41,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":1000000, "monthly_product_id":"749350", "yearly_product_id":"749352", @@ -46,6 +51,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":2000000, "monthly_product_id":"749353", "yearly_product_id":"749355", @@ -55,6 +61,7 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":5000000, "monthly_product_id":"749356", "yearly_product_id":"749357", @@ -64,11 +71,92 @@ }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":10000000, "monthly_product_id":"749358", "yearly_product_id":"749359", "site_limit":50, "team_member_limit":"unlimited", "features":["goals","props","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":10000, + "monthly_product_id":"857481", + "yearly_product_id":"857482", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":100000, + "monthly_product_id":"857483", + "yearly_product_id":"857484", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":200000, + "monthly_product_id":"857486", + "yearly_product_id":"857487", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":500000, + "monthly_product_id":"857490", + "yearly_product_id":"857491", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":1000000, + "monthly_product_id":"857493", + "yearly_product_id":"857494", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":2000000, + "monthly_product_id":"857495", + "yearly_product_id":"857496", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":5000000, + "monthly_product_id":"857498", + "yearly_product_id":"857500", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] + }, + { + "kind":"business", + "generation":3, + "monthly_pageview_limit":10000000, + "monthly_product_id":"857501", + "yearly_product_id":"857502", + "site_limit":50, + "team_member_limit":"unlimited", + "features":["goals","props","revenue_goals","funnels","stats_api"] } ] diff --git a/priv/plans_v4.json b/priv/plans_v4.json index 353dccd67e..8fd801898c 100644 --- a/priv/plans_v4.json +++ b/priv/plans_v4.json @@ -1,146 +1,162 @@ [ { "kind":"growth", + "generation":4, "monthly_pageview_limit":10000, "monthly_product_id":"857097", "yearly_product_id":"857079", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":100000, "monthly_product_id":"857098", "yearly_product_id":"857080", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":200000, "monthly_product_id":"857099", "yearly_product_id":"857081", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":500000, "monthly_product_id":"857100", "yearly_product_id":"857082", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":1000000, "monthly_product_id":"857101", "yearly_product_id":"857083", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":2000000, "monthly_product_id":"857102", "yearly_product_id":"857084", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":5000000, "monthly_product_id":"857103", "yearly_product_id":"857085", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":10000000, "monthly_product_id":"857104", "yearly_product_id":"857086", "site_limit":10, - "team_member_limit":5, + "team_member_limit":3, "features":["goals"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":10000, "monthly_product_id":"857105", "yearly_product_id":"857087", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":100000, "monthly_product_id":"857106", "yearly_product_id":"857088", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":200000, "monthly_product_id":"857107", "yearly_product_id":"857089", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":500000, "monthly_product_id":"857108", "yearly_product_id":"857090", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":1000000, "monthly_product_id":"857109", "yearly_product_id":"857091", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":2000000, "monthly_product_id":"857110", "yearly_product_id":"857092", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":5000000, "monthly_product_id":"857111", "yearly_product_id":"857093", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":10000000, "monthly_product_id":"857112", "yearly_product_id":"857094", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props","revenue_goals","funnels","stats_api"] } ] diff --git a/priv/sandbox_plans.json b/priv/sandbox_plans.json index b0af288193..2d2f8871e1 100644 --- a/priv/sandbox_plans.json +++ b/priv/sandbox_plans.json @@ -1,146 +1,162 @@ [ { "kind":"growth", + "generation":1, "monthly_pageview_limit":10000, "monthly_product_id":"63842", "yearly_product_id":"63859", - "site_limit":10, - "team_member_limit":5, + "site_limit":50, + "team_member_limit":"unlimited", "features":["goals","props","stats_api"] }, { "kind":"growth", + "generation":2, "monthly_pageview_limit":100000, "monthly_product_id":"63843", "yearly_product_id":"63860", - "site_limit":10, - "team_member_limit":5, + "site_limit":50, + "team_member_limit":"unlimited", "features":["goals","props","stats_api"] }, { "kind":"growth", + "generation":3, "monthly_pageview_limit":200000, "monthly_product_id":"63844", "yearly_product_id":"63861", - "site_limit":10, - "team_member_limit":5, + "site_limit":50, + "team_member_limit":"unlimited", "features":["goals","props","stats_api"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":500000, "monthly_product_id":"63845", "yearly_product_id":"63862", "site_limit":10, - "team_member_limit":5, - "features":["goals","props","stats_api"] + "team_member_limit":3, + "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":1000000, "monthly_product_id":"63846", "yearly_product_id":"63863", "site_limit":10, - "team_member_limit":5, - "features":["goals","props","stats_api"] + "team_member_limit":3, + "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":2000000, "monthly_product_id":"63847", "yearly_product_id":"63864", "site_limit":10, - "team_member_limit":5, - "features":["goals","props","stats_api"] + "team_member_limit":3, + "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":5000000, "monthly_product_id":"63848", "yearly_product_id":"63865", "site_limit":10, - "team_member_limit":5, - "features":["goals","props","stats_api"] + "team_member_limit":3, + "features":["goals"] }, { "kind":"growth", + "generation":4, "monthly_pageview_limit":10000000, "monthly_product_id":"63849", "yearly_product_id":"63866", "site_limit":10, - "team_member_limit":5, - "features":["goals","props","stats_api"] + "team_member_limit":3, + "features":["goals"] }, { "kind":"business", + "generation":3, "monthly_pageview_limit":10000, "monthly_product_id":"63850", "yearly_product_id":"63867", "site_limit":50, - "team_member_limit":50, + "team_member_limit":"unlimited", "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":3, "monthly_pageview_limit":100000, "monthly_product_id":"63851", "yearly_product_id":"63868", "site_limit":50, - "team_member_limit":50, + "team_member_limit":"unlimited", "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":3, "monthly_pageview_limit":200000, "monthly_product_id":"63852", "yearly_product_id":"63869", "site_limit":50, - "team_member_limit":50, + "team_member_limit":"unlimited", "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":500000, "monthly_product_id":"63853", "yearly_product_id":"63870", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":1000000, "monthly_product_id":"63854", "yearly_product_id":"63871", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":2000000, "monthly_product_id":"63855", "yearly_product_id":"63872", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":5000000, "monthly_product_id":"63856", "yearly_product_id":"63873", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props", "revenue_goals", "funnels","stats_api"] }, { "kind":"business", + "generation":4, "monthly_pageview_limit":10000000, "monthly_product_id":"63857", "yearly_product_id":"63874", "site_limit":50, - "team_member_limit":50, + "team_member_limit":10, "features":["goals","props", "revenue_goals", "funnels","stats_api"] } ] diff --git a/priv/unlisted_plans_v1.json b/priv/unlisted_plans_v1.json index e660b2bc69..b7fd14b27a 100644 --- a/priv/unlisted_plans_v1.json +++ b/priv/unlisted_plans_v1.json @@ -1,6 +1,7 @@ [ { "kind":"growth", + "generation":1, "monthly_pageview_limit":150000000, "yearly_product_id":"648089", "monthly_product_id":null, diff --git a/priv/unlisted_plans_v2.json b/priv/unlisted_plans_v2.json index 998d48a5c2..ff635879b7 100644 --- a/priv/unlisted_plans_v2.json +++ b/priv/unlisted_plans_v2.json @@ -1,6 +1,7 @@ [ { "kind":"growth", + "generation":2, "monthly_pageview_limit":10000000, "monthly_product_id":"655350", "yearly_product_id":null, diff --git a/test/plausible/billing/plans_test.exs b/test/plausible/billing/plans_test.exs index a0ce1a4b3d..d0f18e32ad 100644 --- a/test/plausible/billing/plans_test.exs +++ b/test/plausible/billing/plans_test.exs @@ -5,30 +5,22 @@ defmodule Plausible.Billing.PlansTest do @v1_plan_id "558018" @v2_plan_id "654177" @v4_plan_id "857097" + @v3_business_plan_id "857481" @v4_business_plan_id "857105" describe "getting subscription plans for user" do - test "growth_plans_for/1 shows v1 pricing for users who are already on v1 pricing" do + test "growth_plans_for/1 returns v1 plans for users who are already on v1 pricing" do user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id)) - assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v1_plan_id end - test "growth_plans_for/1 shows v2 pricing for users who are already on v2 pricing" do + test "growth_plans_for/1 returns v2 plans for users who are already on v2 pricing" do user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id)) - assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v2_plan_id end - test "growth_plans_for/1 shows v2 pricing for users who signed up in 2021" do - user = insert(:user, inserted_at: ~N[2021-12-31 00:00:00]) - - assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v2_plan_id - end - - test "growth_plans_for/1 shows v4 pricing for everyone else" do + test "growth_plans_for/1 returns v4 plans for everyone else" do user = insert(:user) - assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v4_plan_id end @@ -41,11 +33,28 @@ defmodule Plausible.Billing.PlansTest do end) end - test "business_plans/0 returns only v4 business plans" do - Plans.business_plans() - |> Enum.each(fn plan -> - assert plan.kind == :business - end) + test "growth_plans_for/1 returns the latest generation of growth plans for a user with a business subscription" do + user = + insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_business_plan_id)) + + assert List.first(Plans.growth_plans_for(user)).monthly_product_id == @v4_plan_id + end + + test "business_plans_for/1 returns v3 business plans for a legacy subscriber" do + user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id)) + + business_plans = Plans.business_plans_for(user) + + assert Enum.all?(business_plans, &(&1.kind == :business)) + assert List.first(business_plans).monthly_product_id == @v3_business_plan_id + end + + test "business_plans_for/1 returns v4 business plans for everyone else" do + user = insert(:user) + business_plans = Plans.business_plans_for(user) + + assert Enum.all?(business_plans, &(&1.kind == :business)) + assert List.first(business_plans).monthly_product_id == @v4_business_plan_id end test "available_plans_with_prices/1" do @@ -58,7 +67,7 @@ defmodule Plausible.Billing.PlansTest do end) assert Enum.find(business_plans, fn plan -> - (%Money{} = plan.monthly_cost) && plan.monthly_product_id == @v4_business_plan_id + (%Money{} = plan.monthly_cost) && plan.monthly_product_id == @v3_business_plan_id end) end @@ -177,6 +186,14 @@ defmodule Plausible.Billing.PlansTest do "749355", "749357", "749359", + "857482", + "857484", + "857487", + "857491", + "857494", + "857496", + "857500", + "857502", "857079", "857080", "857081", diff --git a/test/plausible/billing/quota_test.exs b/test/plausible/billing/quota_test.exs index a42c01ae6d..36542ba4cf 100644 --- a/test/plausible/billing/quota_test.exs +++ b/test/plausible/billing/quota_test.exs @@ -6,6 +6,7 @@ defmodule Plausible.Billing.QuotaTest do @v1_plan_id "558018" @v2_plan_id "654177" @v3_plan_id "749342" + @v3_business_plan_id "857481" describe "site_limit/1" do test "returns 50 when user is on an old plan" do @@ -356,8 +357,15 @@ defmodule Plausible.Billing.QuotaTest do user_on_business = insert(:user, subscription: build(:business_subscription)) - assert 5 == Quota.team_member_limit(user_on_growth) - assert 50 == Quota.team_member_limit(user_on_business) + assert 3 == Quota.team_member_limit(user_on_growth) + assert 10 == Quota.team_member_limit(user_on_business) + end + + test "returns unlimited when user is on a v3 business plan" do + user = + insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_business_plan_id)) + + assert :unlimited == Quota.team_member_limit(user) end end diff --git a/test/plausible_web/live/choose_plan_test.exs b/test/plausible_web/live/choose_plan_test.exs index 7b58423395..9583b5bebf 100644 --- a/test/plausible_web/live/choose_plan_test.exs +++ b/test/plausible_web/live/choose_plan_test.exs @@ -8,6 +8,7 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do @v1_10k_yearly_plan_id "572810" @v4_growth_200k_yearly_plan_id "857081" @v4_business_5m_monthly_plan_id "857111" + @v3_business_10k_monthly_plan_id "857481" @monthly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="monthly"]/ @yearly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="yearly"]/ @@ -27,6 +28,8 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do @business_current_label "#{@business_plan_box} #current-label" @business_checkout_button "#business-checkout" + @enterprise_plan_box "#enterprise-plan-box" + describe "for a user with no subscription" do setup [:create_user, :log_in] @@ -41,6 +44,41 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do assert doc =~ "+ VAT if applicable" end + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 10 sites" + assert growth_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert growth_box =~ "Email/Slack reports" + assert growth_box =~ "Google Analytics import" + assert growth_box =~ "Goals and custom events" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Up to 10 team members" + assert business_box =~ "Up to 50 sites" + assert business_box =~ "Stats API" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + assert business_box =~ "Priority support" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "10+ team members" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Technical onboarding" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + test "default billing interval is monthly, and can switch to yearly", %{conn: conn} do {:ok, lv, doc} = get_liveview(conn) @@ -107,16 +145,16 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do doc = lv |> element(@slider_input) |> render_change(%{slider: 8}) - assert text_of_element(doc, @growth_plan_box) =~ "Custom" + assert text_of_element(doc, "#growth-custom-price") =~ "Custom" assert text_of_element(doc, @growth_plan_box) =~ "Contact us" - assert text_of_element(doc, @business_plan_box) =~ "Custom" + assert text_of_element(doc, "#business-custom-price") =~ "Custom" assert text_of_element(doc, @business_plan_box) =~ "Contact us" doc = lv |> element(@slider_input) |> render_change(%{slider: 7}) - refute text_of_element(doc, @growth_plan_box) =~ "Custom" + refute text_of_element(doc, "#growth-custom-price") =~ "Custom" refute text_of_element(doc, @growth_plan_box) =~ "Contact us" - refute text_of_element(doc, @business_plan_box) =~ "Custom" + refute text_of_element(doc, "#business-custom-price") =~ "Custom" refute text_of_element(doc, @business_plan_box) =~ "Contact us" end @@ -175,6 +213,41 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do refute doc =~ "What happens if I go over my page views limit?" end + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 10 sites" + assert growth_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert growth_box =~ "Email/Slack reports" + assert growth_box =~ "Google Analytics import" + assert growth_box =~ "Goals and custom events" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Up to 10 team members" + assert business_box =~ "Up to 50 sites" + assert business_box =~ "Stats API" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + assert business_box =~ "Priority support" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "10+ team members" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Technical onboarding" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + test "displays usage", %{conn: conn, user: user} do site = insert(:site, members: [user]) @@ -302,6 +375,51 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do end end + describe "for a user with a v3 business (unlimited team members) subscription plan" do + setup [:create_user, :log_in] + + setup %{user: user} = context do + create_subscription_for(user, paddle_plan_id: @v3_business_10k_monthly_plan_id) + {:ok, context} + end + + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 10 sites" + assert growth_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert growth_box =~ "Email/Slack reports" + assert growth_box =~ "Google Analytics import" + assert growth_box =~ "Goals and custom events" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Unlimited team members" + assert business_box =~ "Up to 50 sites" + assert business_box =~ "Stats API" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + assert business_box =~ "Priority support" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Technical onboarding" + + refute enterprise_box =~ "team members" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + end + describe "for a user with a past_due subscription" do setup [:create_user, :log_in, :create_past_due_subscription] @@ -391,10 +509,13 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do describe "for a grandfathered user" do setup [:create_user, :log_in] - test "on a v1 plan, Growth tiers are available at 20M, 50M, 50M+, but Business tiers are not", - %{conn: conn, user: user} do + setup %{user: user} = context do create_subscription_for(user, paddle_plan_id: @v1_10k_yearly_plan_id) + {:ok, context} + end + test "on a v1 plan, Growth tiers are available at 20M, 50M, 50M+, but Business tiers are not", + %{conn: conn} do {:ok, lv, _doc} = get_liveview(conn) doc = lv |> element(@slider_input) |> render_change(%{slider: 8}) @@ -419,6 +540,42 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do refute text_of_element(doc, @business_plan_box) =~ "Contact us" refute text_of_element(doc, @growth_plan_box) =~ "Contact us" end + + test "displays grandfathering notice in the Growth box instead of benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + growth_box = text_of_element(doc, @growth_plan_box) + assert growth_box =~ "Your subscription has been grandfathered" + refute growth_box =~ "Intuitive, fast and privacy-friendly dashboard" + end + + test "displays business and enterprise plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + assert business_box =~ "Priority support" + + refute business_box =~ "Goals and custom events" + refute business_box =~ "Unlimited team members" + refute business_box =~ "Up to 50 sites" + refute business_box =~ "Stats API" + refute business_box =~ "Custom Properties" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Technical onboarding" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + + refute enterprise_box =~ "10+ team members" + refute enterprise_box =~ "Unlimited team members" + end end describe "for a free_10k subscription" do