Configurable limits for enterprise plans (#3527)

This commit is contained in:
Vinicius Brasil 2023-11-16 21:57:14 -03:00 committed by GitHub
parent d66322e12d
commit cfaa5be8f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 244 additions and 132 deletions

View File

@ -0,0 +1,78 @@
defmodule Plausible.Billing.Ecto.Feature do
@moduledoc """
Ecto type representing a feature. Features are cast and stored in the
database as strings and loaded as modules, for example: `"props"` is loaded
as `Plausible.Billing.Feature.Props`.
"""
use Ecto.Type
def type, do: :string
def cast(feature) when is_binary(feature) do
found =
Enum.find(Plausible.Billing.Feature.list(), fn mod ->
Atom.to_string(mod.name()) == feature
end)
if found, do: {:ok, found}, else: :error
end
def cast(mod) when is_atom(mod) do
{:ok, mod}
end
def load(feature) when is_binary(feature) do
cast(feature)
end
def dump(mod) when is_atom(mod) do
{:ok, Atom.to_string(mod.name())}
end
end
defmodule Plausible.Billing.Ecto.FeatureList do
@moduledoc """
Ecto type representing a list of features. This is a proxy for
`{:array, Plausible.Billing.Ecto.Feature}` and is required for Kaffy to
render the HTML input correctly.
"""
use Ecto.Type
def type, do: {:array, Plausible.Billing.Ecto.Feature}
def cast(list), do: Ecto.Type.cast(type(), list)
def load(list), do: Ecto.Type.load(type(), list)
def dump(list), do: Ecto.Type.dump(type(), list)
def render_form(_conn, changeset, form, field, _options) do
features = Ecto.Changeset.get_field(changeset, field)
checkboxes =
for mod <- Plausible.Billing.Feature.list() do
[
{:safe, ~s(<label style="padding-right: 15px;">)},
Phoenix.HTML.Tag.tag(
:input,
name: Phoenix.HTML.Form.input_name(form, field) <> "[]",
id: Phoenix.HTML.Form.input_id(form, field, mod.name()),
type: "checkbox",
value: mod.name(),
style: "margin-right: 3px;",
checked: mod in features
),
mod.display_name(),
{:safe, ~s(</label>)}
]
end
[
{:safe, ~s(<div class="form-group">)},
Phoenix.HTML.Form.label(form, field),
{:safe, ~s(<div class="form-control">)},
checkboxes,
{:safe, ~s(</div>)},
{:safe, ~s(</div>)}
]
end
end

View File

@ -0,0 +1,39 @@
defmodule Plausible.Billing.Ecto.Limit do
@moduledoc """
Ecto type representing a limit, that can be either a number or unlimited.
Unlimited is dumped to the database as `-1` and loaded as `:unlimited` to
keep compatibility with the rest of the codebase.
"""
use Ecto.Type
def type, do: :integer
def cast(-1), do: {:ok, :unlimited}
def cast(:unlimited), do: {:ok, :unlimited}
def cast(other), do: Ecto.Type.cast(:integer, other)
def load(-1), do: {:ok, :unlimited}
def load(other), do: Ecto.Type.load(:integer, other)
def dump(:unlimited), do: {:ok, -1}
def dump(other), do: Ecto.Type.dump(:integer, other)
def render_form(_conn, changeset, form, field, _options) do
{:ok, value} = changeset |> Ecto.Changeset.get_field(field) |> dump()
[
{:safe, ~s(<div class="form-group">)},
Phoenix.HTML.Form.label(form, field),
Phoenix.HTML.Form.number_input(form, field,
class: "form-control",
name: "#{form.name}[#{field}]",
id: "#{form.name}_#{field}",
value: value,
min: -1
),
{:safe, ~s(<p class="help_text">Use -1 for unlimited.</p>)},
{:safe, ~s(</div>)}
]
end
end

View File

@ -8,15 +8,19 @@ defmodule Plausible.Billing.EnterprisePlan do
:billing_interval,
:monthly_pageview_limit,
:hourly_api_request_limit,
:site_limit
:site_limit,
:features,
:team_member_limit
]
schema "enterprise_plans" do
field :paddle_plan_id, :string
field :billing_interval, Ecto.Enum, values: [:monthly, :yearly]
field :monthly_pageview_limit, :integer
field :hourly_api_request_limit, :integer
field :site_limit, :integer
field :team_member_limit, Plausible.Billing.Ecto.Limit
field :features, Plausible.Billing.Ecto.FeatureList, default: []
field :hourly_api_request_limit, :integer
belongs_to :user, Plausible.Auth.User

View File

@ -8,14 +8,16 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
]
end
def form_fields(_) do
def form_fields(_schema) do
[
user_id: nil,
paddle_plan_id: nil,
billing_interval: %{choices: [{"Yearly", "yearly"}, {"Monthly", "monthly"}]},
monthly_pageview_limit: nil,
site_limit: nil,
team_member_limit: nil,
hourly_api_request_limit: nil,
site_limit: nil
features: nil
]
end
@ -30,10 +32,16 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
paddle_plan_id: nil,
billing_interval: nil,
monthly_pageview_limit: nil,
hourly_api_request_limit: nil,
site_limit: nil
site_limit: nil,
team_member_limit: nil,
hourly_api_request_limit: nil
]
end
defp get_user_email(plan), do: plan.user.email
def update_changeset(enterprise_plan, attrs) do
attrs = Map.put_new(attrs, "features", [])
Plausible.Billing.EnterprisePlan.changeset(enterprise_plan, attrs)
end
end

View File

@ -102,7 +102,6 @@ defmodule Plausible.Billing.Feature do
def check_availability(%Plausible.Auth.User{} = user) do
cond do
Keyword.get(unquote(opts), :free) -> :ok
FunWithFlags.enabled?(:premium_features_private_preview) -> :ok
__MODULE__ in Quota.allowed_features_for(user) -> :ok
true -> {:error, :upgrade_required}
end

View File

@ -123,8 +123,8 @@ defmodule Plausible.Billing.Quota do
user = Plausible.Users.with_subscription(user)
case Plans.get_subscription_plan(user.subscription) do
%EnterprisePlan{} ->
:unlimited
%EnterprisePlan{team_member_limit: limit} ->
limit
%Plan{team_member_limit: limit} ->
limit
@ -252,7 +252,7 @@ defmodule Plausible.Billing.Quota do
user = Plausible.Users.with_subscription(user)
case Plans.get_subscription_plan(user.subscription) do
%EnterprisePlan{} -> Feature.list()
%EnterprisePlan{features: features} -> features
%Plan{features: features} -> features
:free_10k -> [Goals, Props, StatsAPI]
nil -> Feature.list()

View File

@ -5,10 +5,8 @@ defmodule PlausibleWeb.Components.Billing do
import PlausibleWeb.Components.Generic
require Plausible.Billing.Subscription.Status
alias Plausible.Auth.User
alias Plausible.Billing.Feature.{RevenueGoals, Funnels}
alias Plausible.Billing.Feature.{Props, StatsAPI}
alias PlausibleWeb.Router.Helpers, as: Routes
alias Plausible.Billing.{Subscription, Plans, Plan, Subscriptions}
alias Plausible.Billing.{Subscription, Plans, Subscriptions}
attr(:billable_user, User, required: true)
attr(:current_user, User, required: true)
@ -19,42 +17,18 @@ defmodule PlausibleWeb.Components.Billing do
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def premium_feature_notice(assigns) do
legacy_feature_access? =
Timex.before?(assigns.billable_user.inserted_at, Plans.business_tier_launch()) &&
assigns.feature_mod in [StatsAPI, Props]
has_access? = assigns.feature_mod.check_availability(assigns.billable_user) == :ok
cond do
legacy_feature_access? ->
~H""
Plausible.Billing.on_trial?(assigns.billable_user) ->
~H""
not has_access? ->
~H"""
<.notice class="rounded-t-md rounded-b-none" size={@size} {@rest} title="Notice">
<%= account_label(@current_user, @billable_user) %> does not have access to <%= assigns.feature_mod.display_name() %>. To get access to this feature,
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
</.notice>
"""
true ->
~H""
end
end
defp private_preview_end do
private_preview_ends_at = Timex.shift(Plausible.Billing.Plans.business_tier_launch(), days: 8)
days_remaining = Timex.diff(private_preview_ends_at, NaiveDateTime.utc_now(), :day)
cond do
days_remaining <= 0 -> "today"
days_remaining == 1 -> "tomorrow"
true -> "in #{days_remaining} days"
end
~H"""
<.notice
:if={@feature_mod.check_availability(@billable_user) !== :ok}
class="rounded-t-md rounded-b-none"
size={@size}
title="Notice"
{@rest}
>
<%= account_label(@current_user, @billable_user) %> does not have access to <%= @feature_mod.display_name() %>. To get access to this feature,
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
</.notice>
"""
end
attr(:billable_user, User, required: true)
@ -238,8 +212,7 @@ defmodule PlausibleWeb.Components.Billing do
%{
dismissable: true,
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
} =
assigns
} = assigns
) do
~H"""
<aside id="global-subscription-cancelled-notice" class="container">
@ -259,8 +232,7 @@ defmodule PlausibleWeb.Components.Billing do
%{
dismissable: false,
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
} =
assigns
} = assigns
) do
assigns = assign(assigns, :container_id, "local-subscription-cancelled-notice")
@ -336,49 +308,6 @@ defmodule PlausibleWeb.Components.Billing do
def subscription_paused_notice(assigns), do: ~H""
def private_preview_end_notice(assigns) do
user = assigns.user |> Plausible.Users.with_subscription()
features_to_lose =
case Plans.get_subscription_plan(user.subscription) do
nil ->
[]
%Plan{kind: :business} ->
[]
_free_10k_or_enterprise_or_growth ->
used_features = Plausible.Billing.Quota.features_usage(assigns.user)
Enum.filter([Funnels, RevenueGoals], &(&1 in used_features))
end
assigns = assign(assigns, :features_to_lose, features_to_lose)
~H"""
<div
:if={FunWithFlags.enabled?(:premium_features_private_preview) && @features_to_lose != []}
class="container"
>
<.notice
class="shadow-md dark:shadow-none"
title="Notice"
dismissable_id={"premium_features_private_preview_end__#{@user.id}"}
>
Business plans are now live! The private preview of <%= PlausibleWeb.TextHelpers.pretty_join(
Enum.map(@features_to_lose, & &1.display_name())
) %> ends <b><%= private_preview_end() %></b>. If you wish to continue using <%= if length(
@features_to_lose
) == 1,
do:
"this feature",
else:
"these features" %>,
<.upgrade_call_to_action current_user={@user} billable_user={@user} />.
</.notice>
</div>
"""
end
def present_enterprise_plan(assigns) do
~H"""
<ul class="w-full py-4">

View File

@ -108,7 +108,5 @@
subscription={@conn.assigns.current_user.subscription}
class="container"
/>
<.private_preview_end_notice user={@conn.assigns.current_user} />
</div>
<% end %>

View File

@ -84,7 +84,7 @@ defmodule Plausible.MixProject do
{:hammer, "~> 6.0"},
{:httpoison, "~> 1.4"},
{:jason, "~> 1.3"},
{:kaffy, "~> 0.9.4"},
{:kaffy, "~> 0.10.2"},
{:location, git: "https://github.com/plausible/location.git"},
{:mox, "~> 1.0", only: :test},
{:nanoid, "~> 2.0.2"},

View File

@ -68,7 +68,7 @@
"joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"},
"jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
"kaffy": {:hex, :kaffy, "0.9.4", "6a5446cd2c782b8e122061eab409254eb1fa412adb5824169f0528d16775dc45", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "91736c9ddc34a94ed76cb56058fdb6b206c9d777b71856c90ef4554f485f13b9"},
"kaffy": {:hex, :kaffy, "0.10.2", "72e807c525323bd0cbc3ac0c127b7bde61caffdc576fb6554964d3fe6a2a6100", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0.2", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "651cad5f3bcc91510a671c13c7a273b8b8195fdf2d809208708baecbb77300bf"},
"location": {:git, "https://github.com/plausible/location.git", "31f91c4df430cfc16219000ac667cc665826b77c", []},
"locus": {:hex, :locus, "2.3.6", "c9f53fd5df872fca66a54dc0aa2f8b2d3640388e56a0c39a741be0df6d8854bf", [:rebar3], [{:tls_certificate_check, "~> 1.9", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "6087aa9a69673e7011837fb4b3d7f756560adde76892c32f5f93904ee30064e2"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},

View File

@ -0,0 +1,13 @@
defmodule Plausible.Repo.Migrations.AddLimitsToEnterprisePlans do
use Ecto.Migration
def change do
alter table(:enterprise_plans) do
modify :hourly_api_request_limit, :integer, null: false
modify :monthly_pageview_limit, :integer, null: false
modify :site_limit, :integer, null: false
add :team_member_limit, :integer, null: false, default: -1
add :features, {:array, :string}, null: false, default: ["props", "stats_api"]
end
end
end

View File

@ -0,0 +1,51 @@
defmodule Plausible.Billing.EnterprisePlanTest do
use Plausible.DataCase
alias Plausible.Billing.EnterprisePlan
test "changeset/2 loads and dumps the list of features" do
plan = build(:enterprise_plan, user_id: insert(:user).id)
attrs = %{features: ["props", "stats_api"]}
assert {:ok, enterprise_plan} =
plan
|> EnterprisePlan.changeset(attrs)
|> Plausible.Repo.insert()
assert %EnterprisePlan{
features: [Plausible.Billing.Feature.Props, Plausible.Billing.Feature.StatsAPI]
} = enterprise_plan
assert %EnterprisePlan{
features: [Plausible.Billing.Feature.Props, Plausible.Billing.Feature.StatsAPI]
} = Plausible.Repo.get(EnterprisePlan, enterprise_plan.id)
end
test "changeset/2 fails when feature does not exist" do
plan = build(:enterprise_plan, user_id: insert(:user).id)
attrs = %{features: ["ga4_import"]}
assert {:error, changeset} =
plan
|> EnterprisePlan.changeset(attrs)
|> Plausible.Repo.insert()
assert {"is invalid", [type: Plausible.Billing.Ecto.FeatureList, validation: :cast]} ==
changeset.errors[:features]
end
test "changeset/2 loads and dumps limits" do
plan = build(:enterprise_plan, user_id: insert(:user).id)
attrs = %{team_member_limit: :unlimited, monthly_pageview_limit: 10_000}
assert {:ok, enterprise_plan} =
plan
|> EnterprisePlan.changeset(attrs)
|> Plausible.Repo.insert()
assert %EnterprisePlan{team_member_limit: :unlimited, monthly_pageview_limit: 10_000} =
enterprise_plan
assert %EnterprisePlan{team_member_limit: :unlimited, monthly_pageview_limit: 10_000} =
Plausible.Repo.get(EnterprisePlan, enterprise_plan.id)
end
end

View File

@ -7,7 +7,8 @@ defmodule Plausible.Billing.FeatureTest do
test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
enterprise_plan:
build(:enterprise_plan, paddle_plan_id: "123321", features: [unquote(mod)]),
subscription: build(:subscription, paddle_plan_id: "123321")
)
@ -49,7 +50,11 @@ defmodule Plausible.Billing.FeatureTest do
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an enterprise plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
enterprise_plan:
build(:enterprise_plan,
paddle_plan_id: "123321",
features: [Plausible.Billing.Feature.StatsAPI]
),
subscription: build(:subscription, paddle_plan_id: "123321")
)

View File

@ -376,16 +376,6 @@ defmodule Plausible.Billing.QuotaTest do
assert :unlimited == Quota.team_member_limit(user)
end
test "returns unlimited when user is on an enterprise plan" do
user = insert(:user)
enterprise_plan = insert(:enterprise_plan, user_id: user.id)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert :unlimited == Quota.team_member_limit(user)
end
test "returns 5 when user in on trial" do
user =
insert(:user,
@ -406,14 +396,15 @@ defmodule Plausible.Billing.QuotaTest do
assert :unlimited == Quota.team_member_limit(user)
end
test "is unlimited for enterprise customers" do
test "returns the enterprise plan limit" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
enterprise_plan:
build(:enterprise_plan, paddle_plan_id: "123321", team_member_limit: 27),
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert :unlimited == Quota.team_member_limit(user)
assert 27 == Quota.team_member_limit(user)
end
test "reads from json file when the user is on a v4 plan" do
@ -522,20 +513,22 @@ defmodule Plausible.Billing.QuotaTest do
assert [Goals, Props, StatsAPI] == Quota.allowed_features_for(user)
end
test "returns all features when user is on an enterprise plan" do
test "returns the enterprise plan features" do
user = insert(:user)
enterprise_plan =
insert(:enterprise_plan,
user_id: user.id,
monthly_pageview_limit: 100_000,
site_limit: 500
site_limit: 500,
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.Funnels]
)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert Plausible.Billing.Feature.list() == Quota.allowed_features_for(user)
assert [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.Funnels] ==
Quota.allowed_features_for(user)
end
test "returns all features when user in on trial" do
@ -564,25 +557,19 @@ defmodule Plausible.Billing.QuotaTest do
assert Plausible.Billing.Feature.list() == Quota.allowed_features_for(user)
end
test "returns all features for enterprise customers" do
test "returns old plan features for enterprise customers who are due to change a plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert Plausible.Billing.Feature.list() == Quota.allowed_features_for(user)
end
test "returns all features for enterprise customers who are due to change a plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "old-paddle-plan-id"),
enterprise_plan:
build(:enterprise_plan,
paddle_plan_id: "old-paddle-plan-id",
features: [Plausible.Billing.Feature.StatsAPI]
),
subscription: build(:subscription, paddle_plan_id: "old-paddle-plan-id")
)
insert(:enterprise_plan, user_id: user.id, paddle_plan_id: "new-paddle-plan-id")
assert Plausible.Billing.Feature.list() == Quota.allowed_features_for(user)
assert [Plausible.Billing.Feature.StatsAPI] == Quota.allowed_features_for(user)
end
end
end

View File

@ -121,7 +121,8 @@ defmodule Plausible.Factory do
billing_interval: :monthly,
monthly_pageview_limit: 1_000_000,
hourly_api_request_limit: 3000,
site_limit: 100
site_limit: 100,
team_member_limit: 10
}
end