249 lines
6.8 KiB
Elixir
249 lines
6.8 KiB
Elixir
defmodule Plausible.Billing.Feature do
|
|
@moduledoc """
|
|
This module provides an interface for managing features, e.g. Revenue Goals,
|
|
Funnels and Custom Properties.
|
|
|
|
Feature modules have functions for toggling the feature on/off and checking
|
|
whether the feature is available for a site/user.
|
|
|
|
When defining new features, the following options are expected by the
|
|
`__using__` macro:
|
|
|
|
* `:name` - an atom representing the feature name in the plan JSON
|
|
file (see also Plausible.Billing.Plan).
|
|
|
|
* `:display_name` - human-readable display name of the feature
|
|
|
|
* `:toggle_field` - the field in the %Plausible.Site{} schema that toggles
|
|
the feature. If `nil` or not set, toggle/2 silently returns `:ok`
|
|
|
|
* `:free` - if set to `true`, makes the `check_availability/1` function
|
|
always return `:ok` (no matter the user's subscription status)
|
|
|
|
Functions defined by `__using__` can be overridden if needed.
|
|
"""
|
|
|
|
@doc """
|
|
Returns the atom representing the feature name in the plan JSON file.
|
|
"""
|
|
@callback name() :: atom()
|
|
|
|
@doc """
|
|
Returns the human-readable display name of the feature.
|
|
"""
|
|
@callback display_name() :: String.t()
|
|
|
|
@doc """
|
|
Returns the %Plausible.Site{} field that toggles the feature on and off.
|
|
"""
|
|
@callback toggle_field() :: atom()
|
|
|
|
@doc """
|
|
Returns whether the feature is free to use or not.
|
|
"""
|
|
@callback free?() :: boolean()
|
|
|
|
@doc """
|
|
Toggles the feature on and off for a site. Returns
|
|
`{:error, :upgrade_required}` when toggling a feature the site owner does not
|
|
have access to.
|
|
"""
|
|
@callback toggle(Plausible.Site.t(), Plausible.Auth.User.t(), Keyword.t()) ::
|
|
:ok | {:error, :upgrade_required}
|
|
|
|
@doc """
|
|
Checks whether a feature is enabled or not. Returns false when the feature is
|
|
disabled or the user does not have access to it.
|
|
"""
|
|
@callback enabled?(Plausible.Site.t()) :: boolean()
|
|
|
|
@doc """
|
|
Returns whether the site explicitly opted out of the feature. This function
|
|
is different from enabled/1, because enabled/1 returns false when the site
|
|
owner does not have access to the feature.
|
|
"""
|
|
@callback opted_out?(Plausible.Site.t()) :: boolean()
|
|
|
|
@doc """
|
|
Checks whether the team or the team plan includes the given feature.
|
|
"""
|
|
@callback check_availability(Plausible.Teams.Team.t() | nil) ::
|
|
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
|
|
|
|
@features [
|
|
Plausible.Billing.Feature.Props,
|
|
Plausible.Billing.Feature.SharedLinks,
|
|
Plausible.Billing.Feature.Funnels,
|
|
Plausible.Billing.Feature.Goals,
|
|
Plausible.Billing.Feature.RevenueGoals,
|
|
Plausible.Billing.Feature.SiteSegments,
|
|
Plausible.Billing.Feature.SitesAPI,
|
|
Plausible.Billing.Feature.StatsAPI
|
|
]
|
|
|
|
# Generate a union type for features
|
|
@type t() :: unquote(Enum.reduce(@features, &{:|, [], [&1, &2]}))
|
|
|
|
@doc """
|
|
Lists all available feature modules.
|
|
"""
|
|
def list() do
|
|
@features
|
|
end
|
|
|
|
@doc """
|
|
Lists all the feature short names, e.g. RevenueGoals
|
|
"""
|
|
defmacro list_short_names() do
|
|
@features
|
|
|> Enum.map(fn mod ->
|
|
Module.split(mod)
|
|
|> List.last()
|
|
|> String.to_atom()
|
|
end)
|
|
end
|
|
|
|
@doc false
|
|
defmacro __using__(opts \\ []) do
|
|
quote location: :keep do
|
|
@behaviour Plausible.Billing.Feature
|
|
alias Plausible.Billing.Quota
|
|
|
|
@impl true
|
|
def name, do: Keyword.get(unquote(opts), :name)
|
|
|
|
@impl true
|
|
def display_name, do: Keyword.get(unquote(opts), :display_name)
|
|
|
|
@impl true
|
|
def toggle_field, do: Keyword.get(unquote(opts), :toggle_field)
|
|
|
|
@impl true
|
|
def free?, do: Keyword.get(unquote(opts), :free, false)
|
|
|
|
@impl true
|
|
def enabled?(%Plausible.Site{} = site) do
|
|
site = Plausible.Repo.preload(site, :team)
|
|
check_availability(site.team) == :ok && !opted_out?(site)
|
|
end
|
|
|
|
@impl true
|
|
def opted_out?(%Plausible.Site{} = site) do
|
|
if is_nil(toggle_field()), do: false, else: !Map.fetch!(site, toggle_field())
|
|
end
|
|
|
|
@impl true
|
|
def check_availability(team_or_nil) do
|
|
cond do
|
|
free?() -> :ok
|
|
__MODULE__ in Plausible.Teams.Billing.allowed_features_for(team_or_nil) -> :ok
|
|
true -> {:error, :upgrade_required}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def toggle(%Plausible.Site{} = site, %Plausible.Auth.User{} = user, opts \\ []) do
|
|
if toggle_field(), do: do_toggle(site, user, opts), else: :ok
|
|
end
|
|
|
|
defp do_toggle(%Plausible.Site{} = site, user, opts) do
|
|
override = Keyword.get(opts, :override)
|
|
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
|
|
availability = if toggle, do: check_availability(site.team), else: :ok
|
|
|
|
case availability do
|
|
:ok ->
|
|
site
|
|
|> Ecto.Changeset.change(%{toggle_field() => toggle})
|
|
|> Plausible.Repo.update()
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defoverridable check_availability: 1
|
|
end
|
|
end
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.Funnels do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :funnels,
|
|
display_name: "Funnels",
|
|
toggle_field: :funnels_enabled
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.RevenueGoals do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :revenue_goals,
|
|
display_name: "Revenue Goals"
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.Goals do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :goals,
|
|
display_name: "Goals",
|
|
toggle_field: :conversions_enabled,
|
|
free: true
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.Props do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :props,
|
|
display_name: "Custom Properties",
|
|
toggle_field: :props_enabled
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.SharedLinks do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :shared_links,
|
|
display_name: "Shared Links"
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.SiteSegments do
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :site_segments,
|
|
display_name: "Shared Segments"
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.StatsAPI do
|
|
use Plausible
|
|
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :stats_api,
|
|
display_name: "Stats API"
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.SitesAPI do
|
|
use Plausible
|
|
|
|
@moduledoc false
|
|
use Plausible.Billing.Feature,
|
|
name: :sites_api,
|
|
display_name: "Sites API"
|
|
end
|
|
|
|
defmodule Plausible.Billing.Feature.Teams do
|
|
@moduledoc """
|
|
Unlike other feature modules, this one only exists to make feature gating
|
|
settings views more convenient. Other than that, it's not even considered
|
|
a feature on its own. The real access to "Teams" is controlled by the
|
|
team member limit.
|
|
"""
|
|
def check_availability(team) do
|
|
if Plausible.Teams.Billing.solo?(team) do
|
|
{:error, :upgrade_required}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
end
|