Restrict subscribing to a plan when exceeding its limits + warning for losing feature access (#3461)

* fix the styling of the red text notice under checkout link

* avoid some code repetition

* simplify rendering the change_plan_link

* refactor disabling checkout link and showing disabled message

* disable change plan and upgrade link when exceeding pageview limit

* disable checkout when exceeding team member limit

* disable checkout when site limit exceeded

* extract checkout related code in a separate function

* stick to a single order of features

* losing features warning

* fix back link from change-plan-preview

* create Quota.exceeded_limits function

* restrict subscribing with exceeded limits on the API level too

* use with instead of case

Co-authored-by: Vini Brasil <vini@hey.com>

* use :map type instead of :any for user

Co-authored-by: Vini Brasil <vini@hey.com>

* create Quota.usage function

---------

Co-authored-by: Vini Brasil <vini@hey.com>
This commit is contained in:
RobertJoonas 2023-10-26 18:20:38 +03:00 committed by GitHub
parent 0c8b3d7992
commit 8cc7bce689
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 312 additions and 76 deletions

View File

@ -1,7 +1,7 @@
defmodule Plausible.Billing do
use Plausible.Repo
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.Subscription
alias Plausible.Billing.{Subscription, Plans, Quota}
@spec active_subscription_for(integer()) :: Subscription.t() | nil
def active_subscription_for(user_id) do
@ -39,7 +39,13 @@ defmodule Plausible.Billing do
def change_plan(user, new_plan_id) do
subscription = active_subscription_for(user.id)
plan = Plans.find(new_plan_id)
with :ok <- Quota.ensure_can_subscribe_to_plan(user, plan),
do: do_change_plan(subscription, new_plan_id)
end
defp do_change_plan(subscription, new_plan_id) do
res =
paddle_api().update_subscription(subscription.paddle_subscription_id, %{
plan_id: new_plan_id

View File

@ -79,9 +79,9 @@ defmodule Plausible.Billing.Plans do
do: yearly_product_id
end
defp find(nil), do: nil
def find(nil), do: nil
defp find(product_id) do
def find(product_id) do
Enum.find(all(), fn plan ->
product_id in [plan.monthly_product_id, plan.yearly_product_id]
end)

View File

@ -8,6 +8,21 @@ defmodule Plausible.Billing.Quota do
alias Plausible.Billing.{Plan, Plans, Subscription, EnterprisePlan, Feature}
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Funnels, Props}
def usage(user, opts \\ []) do
basic_usage = %{
monthly_pageviews: monthly_pageview_usage(user),
team_members: team_member_usage(user),
sites: site_usage(user)
}
if Keyword.get(opts, :with_features) == true do
basic_usage
|> Map.put(:features, features_usage(user))
else
basic_usage
end
end
@limit_sites_since ~D[2021-05-05]
@spec site_limit(Plausible.Auth.User.t()) :: non_neg_integer() | :unlimited
@doc """
@ -169,10 +184,30 @@ defmodule Plausible.Billing.Quota do
]
Enum.reduce(queries, [], fn {feature, query}, acc ->
if Plausible.Repo.exists?(query), do: [feature | acc], else: acc
if Plausible.Repo.exists?(query), do: acc ++ [feature], else: acc
end)
end
def ensure_can_subscribe_to_plan(user, %Plan{} = plan) do
case exceeded_limits(usage(user), plan) do
[] -> :ok
exceeded_limits -> {:error, %{exceeded_limits: exceeded_limits}}
end
end
def ensure_can_subscribe_to_plan(_user, nil), do: :ok
def exceeded_limits(usage, %Plan{} = plan) do
for {usage_field, limit_field} <- [
{:monthly_pageviews, :monthly_pageview_limit},
{:team_members, :team_member_limit},
{:sites, :site_limit}
],
!within_limit?(Map.get(usage, usage_field), Map.get(plan, limit_field)) do
limit_field
end
end
@doc """
Returns a list of features the user can use. Trial users have the
ability to use all features during their trial.

View File

@ -279,12 +279,28 @@ defmodule PlausibleWeb.Components.Billing do
|> String.replace(".00", "")
end
attr :id, :string, required: true
attr :paddle_product_id, :string, required: true
attr :checkout_disabled, :boolean, default: false
attr :user, :map, required: true
attr :confirm_message, :any, default: nil
slot :inner_block, required: true
def paddle_button(assigns) do
confirmed =
if assigns.confirm_message, do: "confirm(\"#{assigns.confirm_message}\")", else: "true"
assigns = assign(assigns, :confirmed, confirmed)
~H"""
<button
id={@id}
onclick={"Paddle.Checkout.open(#{Jason.encode!(%{product: @paddle_product_id, email: @user.email, disableLogout: true, passthrough: @user.id, success: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), theme: "none"})})"}
class="w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white bg-indigo-600 hover:bg-indigo-500"
onclick={"if (#{@confirmed}) {Paddle.Checkout.open(#{Jason.encode!(%{product: @paddle_product_id, email: @user.email, disableLogout: true, passthrough: @user.id, success: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), theme: "none"})})}"}
class={[
"w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white",
!@checkout_disabled && "bg-indigo-600 hover:bg-indigo-500",
@checkout_disabled && "pointer-events-none bg-gray-400 dark:bg-gray-600"
]}
>
<%= render_slot(@inner_block) %>
</button>

View File

@ -126,7 +126,15 @@ defmodule PlausibleWeb.BillingController do
def change_plan_preview(conn, %{"plan_id" => new_plan_id}) do
with {:ok, {subscription, preview_info}} <-
preview_subscription(conn.assigns.current_user, new_plan_id) do
back_action =
if FunWithFlags.enabled?(:business_tier, for: conn.assigns.current_user) do
:choose_plan
else
:change_plan_form
end
render(conn, "change_plan_preview.html",
back_link: Routes.billing_path(conn, back_action),
skip_plausible_tracking: true,
subscription: subscription,
preview_info: preview_info,
@ -139,17 +147,20 @@ defmodule PlausibleWeb.BillingController do
end
def change_plan(conn, %{"new_plan_id" => new_plan_id}) do
case Billing.change_plan(conn.assigns[:current_user], new_plan_id) do
case Billing.change_plan(conn.assigns.current_user, new_plan_id) do
{:ok, _subscription} ->
conn
|> put_flash(:success, "Plan changed successfully")
|> redirect(to: "/settings")
{:error, e} ->
# https://developer.paddle.com/api-reference/intro/api-error-codes
msg =
case e do
%{exceeded_limits: exceeded_limits} ->
"Unable to subscribe to this plan because the following limits are exceeded: #{inspect(exceeded_limits)}"
%{"code" => 147} ->
# https://developer.paddle.com/api-reference/intro/api-error-codes
"We were unable to charge your card. Click 'update billing info' to update your payment details and try again."
%{"message" => msg} when not is_nil(msg) ->

View File

@ -23,7 +23,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
Users.with_subscription(user_id)
end)
|> assign_new(:usage, fn %{user: user} ->
Quota.monthly_pageview_usage(user)
Quota.usage(user, with_features: true)
end)
|> assign_new(:owned_plan, fn %{user: %{subscription: subscription}} ->
Plans.get_regular_plan(subscription, only_non_expired: true)
@ -42,7 +42,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
usage: usage,
available_volumes: available_volumes
} ->
default_selected_volume(owned_plan, usage, available_volumes)
default_selected_volume(owned_plan, usage.monthly_pageviews, available_volumes)
end)
|> assign_new(:selected_interval, fn %{current_interval: current_interval} ->
current_interval || :monthly
@ -118,7 +118,8 @@ defmodule PlausibleWeb.Live.ChoosePlan do
<.enterprise_plan_box benefits={@enterprise_benefits} />
</div>
<p class="mx-auto mt-8 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-gray-400">
<.usage usage={@usage} />
You have used <b><%= PlausibleWeb.AuthView.delimit_integer(@usage.monthly_pageviews) %></b>
billable pageviews in the last 30 days
</p>
<.pageview_limit_notice :if={!@owned_plan} />
<.help_links />
@ -160,8 +161,8 @@ defmodule PlausibleWeb.Live.ChoosePlan do
defp default_selected_volume(%Plan{monthly_pageview_limit: limit}, _, _), do: limit
defp default_selected_volume(_, usage, available_volumes) do
Enum.find(available_volumes, &(usage < &1)) || :enterprise
defp default_selected_volume(_, pageview_usage, available_volumes) do
Enum.find(available_volumes, &(pageview_usage < &1)) || :enterprise
end
defp current_user_subscription_interval(subscription) do
@ -261,30 +262,10 @@ defmodule PlausibleWeb.Live.ChoosePlan do
</div>
<div>
<.render_price_info available={@available} {assigns} />
<%= cond do %>
<% !@available -> %>
<.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" />
<% @owned_plan && Plausible.Billing.Subscriptions.resumable?(@user.subscription) -> %>
<.render_change_plan_link
paddle_product_id={get_paddle_product_id(@plan_to_render, @selected_interval)}
text={
change_plan_link_text(
@owned_plan,
@plan_to_render,
@current_interval,
@selected_interval
)
}
{assigns}
/>
<% true -> %>
<.paddle_button
id={"#{@kind}-checkout"}
paddle_product_id={get_paddle_product_id(@plan_to_render, @selected_interval)}
{assigns}
>
Upgrade
</.paddle_button>
<%= if @available do %>
<.checkout id={"#{@kind}-checkout"} {assigns} />
<% else %>
<.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" />
<% end %>
</div>
<%= if @kind == :growth && @plan_to_render.generation < 4 do %>
@ -301,6 +282,67 @@ defmodule PlausibleWeb.Live.ChoosePlan do
"""
end
defp checkout(assigns) do
paddle_product_id = get_paddle_product_id(assigns.plan_to_render, assigns.selected_interval)
change_plan_link_text = change_plan_link_text(assigns)
exceeds_some_limit = Quota.exceeded_limits(assigns.usage, assigns.plan_to_render) != []
billing_details_expired =
assigns.user.subscription &&
assigns.user.subscription.status in [
Subscription.Status.paused(),
Subscription.Status.past_due()
]
{checkout_disabled, disabled_message} =
cond do
change_plan_link_text == "Currently on this plan" ->
{true, nil}
assigns.available && exceeds_some_limit ->
{true, "Your usage exceeds this plan"}
billing_details_expired ->
{true, "Please update your billing details first"}
true ->
{false, nil}
end
features_to_lose = assigns.usage.features -- assigns.plan_to_render.features
assigns =
assigns
|> assign(:paddle_product_id, paddle_product_id)
|> assign(:change_plan_link_text, change_plan_link_text)
|> assign(:checkout_disabled, checkout_disabled)
|> assign(:disabled_message, disabled_message)
|> assign(:confirm_message, losing_features_message(features_to_lose))
~H"""
<%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@user.subscription) do %>
<.change_plan_link {assigns} />
<% else %>
<.paddle_button {assigns}>Upgrade</.paddle_button>
<% end %>
<p :if={@disabled_message} class="h-0 text-center text-sm text-red-700 dark:text-red-500">
<%= @disabled_message %>
</p>
"""
end
defp losing_features_message([]), do: nil
defp losing_features_message(features_to_lose) do
features_list_str =
features_to_lose
|> Enum.map(& &1.display_name)
|> PlausibleWeb.TextHelpers.pretty_join()
"This plan does not support #{features_list_str}, which you are currently using. Please note that by subscribing to this plan you will lose access to #{if length(features_to_lose) == 1, do: "this feature", else: "these features"}."
end
defp growth_grandfathering_notice(assigns) do
~H"""
<ul class="mt-8 space-y-3 text-sm leading-6 text-gray-600 text-justify dark:text-gray-100 xl:mt-10">
@ -333,39 +375,20 @@ defmodule PlausibleWeb.Live.ChoosePlan do
"""
end
defp render_change_plan_link(assigns) do
~H"""
<.change_plan_link
plan_already_owned={@text == "Currently on this plan"}
billing_details_expired={
@user.subscription &&
@user.subscription.status in [Subscription.Status.past_due(), Subscription.Status.paused()]
}
{assigns}
/>
"""
end
defp change_plan_link(assigns) do
~H"""
<.link
id={"#{@kind}-checkout"}
onclick={if @confirm_message, do: "if (!confirm(\"#{@confirm_message}\")) {e.preventDefault()}"}
href={Routes.billing_path(PlausibleWeb.Endpoint, :change_plan_preview, @paddle_product_id)}
class={[
"w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white",
!(@plan_already_owned || @billing_details_expired) && "bg-indigo-600 hover:bg-indigo-500",
(@plan_already_owned || @billing_details_expired) &&
"pointer-events-none bg-gray-400 dark:bg-gray-600"
!@checkout_disabled && "bg-indigo-600 hover:bg-indigo-500",
@checkout_disabled && "pointer-events-none bg-gray-400 dark:bg-gray-600"
]}
>
<%= @text %>
<%= @change_plan_link_text %>
</.link>
<p
:if={@billing_details_expired && !@plan_already_owned}
class="text-center text-sm text-red-700 dark:text-red-500"
>
Please update your billing details first
</p>
"""
end
@ -446,13 +469,6 @@ defmodule PlausibleWeb.Live.ChoosePlan do
"""
end
defp usage(assigns) do
~H"""
You have used <b><%= PlausibleWeb.AuthView.delimit_integer(@usage) %></b>
billable pageviews in the last 30 days
"""
end
defp pageview_limit_notice(assigns) do
~H"""
<div class="mt-12 mx-auto mt-6 max-w-2xl">
@ -585,10 +601,12 @@ defmodule PlausibleWeb.Live.ChoosePlan do
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp change_plan_link_text(
%Plan{kind: from_kind, monthly_pageview_limit: from_volume},
%Plan{kind: to_kind, monthly_pageview_limit: to_volume},
from_interval,
to_interval
%{
owned_plan: %Plan{kind: from_kind, monthly_pageview_limit: from_volume},
plan_to_render: %Plan{kind: to_kind, monthly_pageview_limit: to_volume},
current_interval: from_interval,
selected_interval: to_interval
} = _assigns
) do
cond do
from_kind == :business && to_kind == :growth ->
@ -611,6 +629,8 @@ defmodule PlausibleWeb.Live.ChoosePlan do
end
end
defp change_plan_link_text(_), do: nil
defp get_available_volumes(%{business: business_plans, growth: growth_plans}) do
growth_volumes = Enum.map(growth_plans, & &1.monthly_pageview_limit)
business_volumes = Enum.map(business_plans, & &1.monthly_pageview_limit)

View File

@ -66,7 +66,7 @@
<div class="flex items-center justify-between mt-10">
<span class="flex rounded-md shadow-sm">
<a href="<%= Routes.billing_path(@conn, :change_plan_form) %>" type="button" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-200 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 dark:active:text-gray-200 active:bg-gray-50 transition ease-in-out duration-150">
<a href="<%= @back_link %>" type="button" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-200 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 dark:active:text-gray-200 active:bg-gray-50 transition ease-in-out duration-150">
Back
</a>
</span>

View File

@ -0,0 +1,33 @@
defmodule PlausibleWeb.TextHelpers do
@moduledoc false
@spec pretty_join([String.t()]) :: String.t()
@doc """
Turns a list of strings into a string and replaces the last comma
with the word "and".
### Examples:
iex> ["one"] |> PlausibleWeb.TextHelpers.pretty_join()
"one"
iex> ["one", "two"] |> PlausibleWeb.TextHelpers.pretty_join()
"one and two"
iex> ["one", "two", "three"] |> PlausibleWeb.TextHelpers.pretty_join()
"one, two and three"
"""
def pretty_join([str]), do: str
def pretty_join(list) do
[last_string | rest] = Enum.reverse(list)
rest_string =
rest
|> Enum.reverse()
|> Enum.join(", ")
"#{rest_string} and #{last_string}"
end
end

View File

@ -1,6 +1,6 @@
defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true
alias Plausible.Billing.Quota
alias Plausible.Billing.{Quota, Plans}
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Funnels, Props, StatsAPI}
@legacy_plan_id "558746"
@ -115,6 +115,20 @@ defmodule Plausible.Billing.QuotaTest do
end
end
describe "exceeded_limits/2" do
test "returns limits that are exceeded" do
usage = %{
monthly_pageviews: 10_001,
team_members: 2,
sites: 51
}
plan = Plans.find(@v3_plan_id)
assert Quota.exceeded_limits(usage, plan) == [:monthly_pageview_limit, :site_limit]
end
end
describe "monthly_pageview_limit/1" do
test "is based on the plan if user is on a legacy plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @legacy_plan_id))
@ -427,7 +441,7 @@ defmodule Plausible.Billing.QuotaTest do
steps = Enum.map(goals, &%{"goal_id" => &1.id})
Plausible.Funnels.create(site, "dummy", steps)
assert [RevenueGoals, Funnels, Props] == Quota.features_usage(user)
assert [Props, Funnels, RevenueGoals] == Quota.features_usage(user)
end
test "accounts only for sites the user owns" do

View File

@ -4,6 +4,8 @@ defmodule PlausibleWeb.BillingControllerTest do
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.Subscription
@v4_growth_plan "857097"
describe "GET /upgrade" do
setup [:create_user, :log_in]
@ -75,6 +77,25 @@ defmodule PlausibleWeb.BillingControllerTest do
describe "POST /change-plan" do
setup [:create_user, :log_in]
test "errors if usage exceeds some limit on the new plan", %{conn: conn, user: user} do
insert(:subscription, user: user, paddle_plan_id: "123123")
insert(:site,
memberships: [
build(:site_membership, user: user, role: :owner),
build(:site_membership, user: build(:user)),
build(:site_membership, user: build(:user)),
build(:site_membership, user: build(:user)),
build(:site_membership, user: build(:user))
]
)
conn = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan))
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Unable to subscribe to this plan because the following limits are exceeded: [:team_member_limit]"
end
test "calls Paddle API to update subscription", %{conn: conn, user: user} do
insert(:subscription, user: user)

View File

@ -37,7 +37,9 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
{:ok, _lv, doc} = get_liveview(conn)
assert doc =~ "Upgrade your account"
assert doc =~ "You have used <b>0</b>\nbillable pageviews in the last 30 days"
assert doc =~ "You have used"
assert doc =~ "<b>0</b>"
assert doc =~ "billable pageviews in the last 30 days"
assert doc =~ "Questions?"
assert doc =~ "What happens if I go over my page views limit?"
assert doc =~ "Enterprise"
@ -200,6 +202,33 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
assert get_paddle_checkout_params(find(doc, @business_checkout_button))["product"] ==
@v4_business_5m_monthly_plan_id
end
test "checkout is disabled when pageview usage exceeds rendered plan limit", %{
conn: conn,
user: user
} do
site = insert(:site, members: [user])
generate_usage_for(site, 10_001)
{:ok, lv, _doc} = get_liveview(conn)
doc = lv |> element(@slider_input) |> render_change(%{slider: 0})
assert text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan"
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
assert text_of_element(doc, @business_plan_box) =~ "Your usage exceeds this plan"
assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none"
end
test "warns about losing access to a feature", %{conn: conn, user: user} do
site = insert(:site, members: [user])
Plausible.Props.allow(site, ["author"])
{:ok, _lv, doc} = get_liveview(conn)
assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~
"if (confirm(\"This plan does not support Custom Properties, which you are currently using. Please note that by subscribing to this plan you will lose access to this feature.\")) {Paddle.Checkout.open"
end
end
describe "for a user with a v4 growth subscription plan" do
@ -257,7 +286,9 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
])
{:ok, _lv, doc} = get_liveview(conn)
assert doc =~ "You have used <b>2</b>\nbillable pageviews in the last 30 days"
assert doc =~ "You have used"
assert doc =~ "<b>2</b>"
assert doc =~ "billable pageviews in the last 30 days"
end
test "gets default selected interval from current subscription plan", %{conn: conn} do
@ -373,6 +404,49 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
assert text_of_element(doc, @business_checkout_button) == "Downgrade"
assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth"
end
test "checkout is disabled when team member usage exceeds rendered plan limit", %{
conn: conn,
user: user
} do
insert(:site,
memberships: [
build(:site_membership, user: user, role: :owner),
build(:site_membership, user: build(:user)),
build(:site_membership, user: build(:user)),
build(:site_membership, user: build(:user))
]
)
{:ok, _lv, doc} = get_liveview(conn)
assert text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan"
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
end
test "checkout is disabled when sites usage exceeds rendered plan limit", %{
conn: conn,
user: user
} do
for _ <- 1..11, do: insert(:site, members: [user])
{:ok, _lv, doc} = get_liveview(conn)
assert text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan"
assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
end
test "warns about losing access to a feature", %{conn: conn, user: user} do
site = insert(:site, members: [user])
Plausible.Props.allow(site, ["author"])
insert(:goal, currency: :USD, site: site, event_name: "Purchase")
{:ok, _lv, doc} = get_liveview(conn)
assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~
"if (!confirm(\"This plan does not support Custom Properties and Revenue Goals, which you are currently using. Please note that by subscribing to this plan you will lose access to these features.\")) {e.preventDefault()}"
end
end
describe "for a user with a v3 business (unlimited team members) subscription plan" do

View File

@ -139,6 +139,12 @@ defmodule Plausible.TestUtils do
|> Plug.Conn.fetch_session()
end
def generate_usage_for(site, i) do
events = for _i <- 1..i, do: Factory.build(:pageview)
populate_stats(site, events)
:ok
end
def populate_stats(site, events) do
Enum.map(events, fn event ->
case event do