| 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_display_name` - human-readable display name used in dashboard |
| toggle notifications. Defaults to `:display_name` if not set. |
| |
| * `:toggle_field` - the field in the %Plausible.Site{} schema that toggles |
| the feature. If `nil` or not set, toggle/2 silently returns `:ok` |
| |
| 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 """ |
| 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.SiteAnnotations, |
| Plausible.Billing.Feature.SitesAPI, |
| Plausible.Billing.Feature.StatsAPI, |
| Plausible.Billing.Feature.SSO, |
| Plausible.Billing.Feature.ConsolidatedView |
| ] |
|
|
| |
| @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) |
|
|
| def toggle_display_name do |
| Keyword.get(unquote(opts), :toggle_display_name, display_name()) |
| end |
|
|
| @impl true |
| def toggle_field, do: Keyword.get(unquote(opts), :toggle_field) |
|
|
| @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 |
| if __MODULE__ in Plausible.Teams.Billing.allowed_features_for(team_or_nil) do |
| :ok |
| else |
| {: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 and user journeys", |
| toggle_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 |
| 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.SiteAnnotations do |
| @moduledoc false |
| use Plausible.Billing.Feature, |
| name: :site_annotations, |
| display_name: "Shared Annotations" |
| 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.SSO do |
| use Plausible |
|
|
| @moduledoc false |
| use Plausible.Billing.Feature, |
| name: :sso, |
| display_name: "Single Sign-On" |
| end |
|
|
| defmodule Plausible.Billing.Feature.ConsolidatedView do |
| use Plausible |
|
|
| @moduledoc false |
| use Plausible.Billing.Feature, |
| name: :consolidated_view, |
| display_name: "Consolidated View" |
| 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 |
|
|