From cfaa5be8f4e9a21921bf1e9f0c5187334b7e79ba Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Thu, 16 Nov 2023 21:57:14 -0300 Subject: [PATCH] Configurable limits for enterprise plans (#3527) --- lib/plausible/billing/ecto/feature.ex | 78 ++++++++++++++ lib/plausible/billing/ecto/limit.ex | 39 +++++++ lib/plausible/billing/enterprise_plan.ex | 8 +- .../billing/enterprise_plan_admin.ex | 16 ++- lib/plausible/billing/feature.ex | 1 - lib/plausible/billing/quota.ex | 6 +- lib/plausible_web/components/billing.ex | 101 +++--------------- .../templates/layout/_notice.html.heex | 2 - mix.exs | 2 +- mix.lock | 2 +- ...5131025_add_limits_to_enterprise_plans.exs | 13 +++ .../billing/enterprise_plan_test.exs | 51 +++++++++ test/plausible/billing/feature_test.exs | 9 +- test/plausible/billing/quota_test.exs | 45 +++----- test/support/factory.ex | 3 +- 15 files changed, 244 insertions(+), 132 deletions(-) create mode 100644 lib/plausible/billing/ecto/feature.ex create mode 100644 lib/plausible/billing/ecto/limit.ex create mode 100644 priv/repo/migrations/20231115131025_add_limits_to_enterprise_plans.exs create mode 100644 test/plausible/billing/enterprise_plan_test.exs diff --git a/lib/plausible/billing/ecto/feature.ex b/lib/plausible/billing/ecto/feature.ex new file mode 100644 index 000000000..98ac24e8c --- /dev/null +++ b/lib/plausible/billing/ecto/feature.ex @@ -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()} + ] + end + + [ + {:safe, ~s(
)}, + Phoenix.HTML.Form.label(form, field), + {:safe, ~s(
)}, + checkboxes, + {:safe, ~s(
)}, + {:safe, ~s(
)} + ] + end +end diff --git a/lib/plausible/billing/ecto/limit.ex b/lib/plausible/billing/ecto/limit.ex new file mode 100644 index 000000000..6848ee419 --- /dev/null +++ b/lib/plausible/billing/ecto/limit.ex @@ -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(
)}, + 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(

Use -1 for unlimited.

)}, + {:safe, ~s(
)} + ] + end +end diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex index cfae0d4ff..54fa7be42 100644 --- a/lib/plausible/billing/enterprise_plan.ex +++ b/lib/plausible/billing/enterprise_plan.ex @@ -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 diff --git a/lib/plausible/billing/enterprise_plan_admin.ex b/lib/plausible/billing/enterprise_plan_admin.ex index 08c396732..bab737530 100644 --- a/lib/plausible/billing/enterprise_plan_admin.ex +++ b/lib/plausible/billing/enterprise_plan_admin.ex @@ -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 diff --git a/lib/plausible/billing/feature.ex b/lib/plausible/billing/feature.ex index 4d55cb17b..3c5ed0b47 100644 --- a/lib/plausible/billing/feature.ex +++ b/lib/plausible/billing/feature.ex @@ -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 diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex index f9e682a1f..cc9dace54 100644 --- a/lib/plausible/billing/quota.ex +++ b/lib/plausible/billing/quota.ex @@ -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() diff --git a/lib/plausible_web/components/billing.ex b/lib/plausible_web/components/billing.ex index 073ef1f42..380e68c41 100644 --- a/lib/plausible_web/components/billing.ex +++ b/lib/plausible_web/components/billing.ex @@ -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} />. - - """ - - 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} />. + + """ 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"""