defmodule Plausible.Teams.Billing do @moduledoc false use Plausible import Ecto.Query alias Plausible.Billing.EnterprisePlan alias Plausible.Billing.Subscription alias Plausible.Billing.Subscriptions alias Plausible.Repo alias Plausible.Teams alias Plausible.Billing.{EnterprisePlan, Feature, Plan, Plans, Quota} alias Plausible.Billing.Feature.{Goals, Props, SitesAPI, StatsAPI, SharedLinks, SSO} require Plausible.Billing.Subscription.Status # Features that are always available, regardless of team plan @free_features [Goals] @limit_sites_since ~D[2021-05-05] @max_sites_for_usage_breakdown 10 def max_sites_for_usage_breakdown, do: @max_sites_for_usage_breakdown @typep last_30_days_usage() :: %{:last_30_days => Quota.usage_cycle()} @typep monthly_pageview_usage() :: Quota.cycles_usage() | last_30_days_usage() def free_features(), do: @free_features def grandfathered_team?(nil), do: false def grandfathered_team?(team) do # timestamps were originally rewritten from owner.inserted_at Date.before?(team.inserted_at, @limit_sites_since) end def get_subscription(nil), do: nil def get_subscription(%Teams.Team{subscription: %Subscription{} = subscription}), do: subscription def get_subscription(%Teams.Team{} = team) do Teams.with_subscription(team).subscription end def change_plan(team, new_plan_id) do subscription = active_subscription_for(team) plan = Plausible.Billing.Plans.find(new_plan_id) limit_checking_opts = if team.allow_next_upgrade_override do [skip_pageview_limit_check?: true] else [] end usage = quota_usage(team) with :ok <- Plausible.Billing.Quota.ensure_within_plan_limits(usage, plan, limit_checking_opts), do: do_change_plan(subscription, new_plan_id) end defp do_change_plan(subscription, new_plan_id) do res = Plausible.Billing.paddle_api().update_subscription(subscription.paddle_subscription_id, %{ plan_id: new_plan_id }) case res do {:ok, response} -> amount = :erlang.float_to_binary(response["next_payment"]["amount"] / 1, decimals: 2) Subscription.changeset(subscription, %{ paddle_plan_id: Integer.to_string(response["plan_id"]), next_bill_amount: amount, next_bill_date: response["next_payment"]["date"] }) |> Repo.update() e -> e end end def enterprise_configured?(nil), do: false def enterprise_configured?(%Teams.Team{} = team) do team |> Ecto.assoc(:enterprise_plan) |> Repo.exists?() end def latest_enterprise_plan_with_price(team, customer_ip) do enterprise_plan = Repo.one!( from(e in EnterprisePlan, where: e.team_id == ^team.id, order_by: [desc: e.inserted_at], limit: 1 ) ) {enterprise_plan, Plausible.Billing.Plans.get_price_for(enterprise_plan, customer_ip)} end def has_active_subscription?(nil), do: false def has_active_subscription?(team) do team |> active_subscription_query() |> Repo.exists?() end def active_subscription_for(nil), do: nil def active_subscription_for(team) do team |> active_subscription_query() |> Repo.one() end @spec check_needs_to_upgrade(Teams.Team.t() | nil, atom()) :: {:needs_to_upgrade, :no_active_trial_or_subscription | :grace_period_ended} | :no_upgrade_needed def check_needs_to_upgrade(team_or_nil, usage_mod \\ Teams.Billing) def check_needs_to_upgrade(nil, _usage_mod), do: {:needs_to_upgrade, :no_active_trial_or_subscription} def check_needs_to_upgrade(team, usage_mod) do team = Teams.with_subscription(team) cond do Plausible.Teams.on_trial?(team) -> :no_upgrade_needed not Subscriptions.active?(team.subscription) -> {:needs_to_upgrade, :no_active_trial_or_subscription} Teams.GracePeriod.expired?(team) -> revise_pageview_usage(team, usage_mod) true -> :no_upgrade_needed end end defp revise_pageview_usage(team, usage_mod) do case Plausible.Workers.CheckUsage.check_pageview_usage_two_cycles(team, usage_mod) do {:over_limit, _} -> {:needs_to_upgrade, :grace_period_ended} {:below_limit, _} -> Plausible.Teams.remove_grace_period(team) :no_upgrade_needed end end @doc """ Enterprise plans are always allowed to add more sites (even when over limit) to avoid service disruption. Their usage is checked in a background job instead (see `check_usage.ex`). """ def ensure_can_add_new_site(nil) do :ok end def ensure_can_add_new_site(team) do team = Teams.with_subscription(team) case Plans.get_subscription_plan(team.subscription) do %EnterprisePlan{} -> :ok _ -> usage = site_usage(team) limit = site_limit(team) if Plausible.Billing.Quota.below_limit?(usage, limit) do :ok else {:error, {:over_limit, limit}} end end end on_ee do @site_limit_for_trials 10 def site_limit(nil) do @site_limit_for_trials end def site_limit(team) do if grandfathered_team?(team) do :unlimited else get_site_limit_from_plan(team) end end defp get_site_limit_from_plan(team) do team = Teams.with_subscription(team) case Plans.get_subscription_plan(team.subscription) do %{site_limit: site_limit} -> site_limit :free_10k -> 50 nil -> @site_limit_for_trials end end else def site_limit(_team), do: :unlimited end @doc """ Returns the number of sites the given team owns. """ @spec site_usage(Teams.Team.t() | nil) :: non_neg_integer() def site_usage(nil), do: 0 def site_usage(team) do Teams.owned_sites_count(team) end on_ee do @team_member_limit_for_trials 10 def team_member_limit(nil) do @team_member_limit_for_trials end def team_member_limit(team) do team = Teams.with_subscription(team) case Plans.get_subscription_plan(team.subscription) do %{team_member_limit: limit} -> limit :free_10k -> :unlimited nil -> @team_member_limit_for_trials end end def solo?(nil), do: true def solo?(team) do team_member_limit(team) == 0 end else def team_member_limit(_team), do: :unlimited def solo?(_team), do: always(false) end @doc """ Returns a full usage report for the team. ### Options * `pending_ownership_site_ids` - a list of site IDs from which to count additional usage. This allows us to look at the total usage from pending ownerships and owned sites at the same time, which is useful, for example, when deciding whether to let the team owner upgrade to a plan, or accept a site ownership. * `with_features` - when `true`, the returned map will contain features usage. Also counts usage from `pending_ownership_site_ids` if that option is given. """ def quota_usage(team, opts \\ []) do team = Teams.with_subscription(team) with_features? = Keyword.get(opts, :with_features, false) pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) team_site_ids = Teams.owned_sites_ids(team) all_site_ids = pending_site_ids ++ team_site_ids monthly_pageviews = monthly_pageview_usage(team, all_site_ids) team_member_usage = team_member_usage(team, pending_ownership_site_ids: pending_site_ids) basic_usage = %{ monthly_pageviews: monthly_pageviews, team_members: team_member_usage, sites: length(all_site_ids) } if with_features? do Map.put(basic_usage, :features, features_usage(team, all_site_ids)) else basic_usage end end @monthly_pageview_limit_for_free_10k 10_000 @monthly_pageview_limit_for_trials :unlimited def monthly_pageview_limit(nil) do @monthly_pageview_limit_for_trials end def monthly_pageview_limit(%Teams.Team{} = team) do team = Teams.with_subscription(team) monthly_pageview_limit(team.subscription) end def monthly_pageview_limit(subscription) do case Plans.get_subscription_plan(subscription) do %EnterprisePlan{monthly_pageview_limit: limit} -> limit %Plan{monthly_pageview_limit: limit} -> limit :free_10k -> @monthly_pageview_limit_for_free_10k _any -> Sentry.capture_message("Unknown monthly pageview limit for plan", extra: %{paddle_plan_id: subscription.paddle_plan_id} ) @monthly_pageview_limit_for_trials end end @doc """ Queries the ClickHouse database for the monthly pageview usage. If the given team's subscription is `active`, `past_due`, or a `deleted` (but not yet expired), a map with the following structure is returned: ```elixir %{ current_cycle: usage_cycle(), last_cycle: usage_cycle(), penultimate_cycle: usage_cycle() } ``` In all other cases of the subscription status (or a `free_10k` subscription which does not have a `last_bill_date` defined) - the following structure is returned: ```elixir %{last_30_days: usage_cycle()} ``` Given only a team as input, the usage is queried from across all the sites that the team owns. Alternatively, given an optional argument of `site_ids`, the usage from across all those sites is queried instead. """ @spec monthly_pageview_usage(Teams.Team.t() | nil, list() | nil) :: monthly_pageview_usage() def monthly_pageview_usage(team, site_ids \\ nil) def monthly_pageview_usage(team, nil) do monthly_pageview_usage(team, Teams.owned_sites_ids(team)) end def monthly_pageview_usage(nil, _site_ids) do %{last_30_days: usage_cycle(nil, :last_30_days, [])} end def monthly_pageview_usage(team, site_ids) do team = Teams.with_subscription(team) active_subscription? = Subscriptions.active?(team.subscription) if active_subscription? and team.subscription.last_bill_date != nil do [:current_cycle, :last_cycle, :penultimate_cycle] |> Task.async_stream(fn cycle -> {cycle, usage_cycle(team, cycle, site_ids)} end) |> Enum.into(%{}, fn {:ok, cycle_usage} -> cycle_usage end) else %{last_30_days: usage_cycle(team, :last_30_days, site_ids)} end end @spec team_member_usage(Teams.Team.t() | nil, Keyword.t()) :: non_neg_integer() @doc """ Returns the total count of team members associated with the team's sites. * The given team's owner is not counted as a team member. * Pending invitations (but not ownership transfers) are counted as team members even before accepted. * Users are counted uniquely - i.e. even if an account is associated with many sites owned by the given user, they still count as one team member. ### Options * `exclude_emails` - a list of emails to not count towards the usage. This allows us to exclude a user from being counted as a team member when checking whether a site invitation can be created for that same user. * `pending_ownership_site_ids` - a list of site IDs from which to count additional team member usage. Without this option, usage is queried only across sites owned by the given user. """ def team_member_usage(team, opts \\ []) def team_member_usage(nil, _), do: 0 def team_member_usage(team, opts) do [owner | _] = Repo.preload(team, :owners).owners exclude_emails = Keyword.get(opts, :exclude_emails, []) ++ [owner.email] pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) team |> query_team_member_emails(pending_site_ids, exclude_emails) |> Repo.aggregate(:count) end def usage_cycle(team, cycle, owned_site_ids \\ nil, today \\ Date.utc_today()) def usage_cycle(team, cycle, nil, today) do owned_site_ids = Teams.owned_sites_ids(team) usage_cycle(team, cycle, owned_site_ids, today) end def usage_cycle(_team, :last_30_days, owned_site_ids, today) do date_range = Date.range(Date.shift(today, day: -30), today) {pageviews, custom_events} = Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range) %{ date_range: date_range, pageviews: pageviews, custom_events: custom_events, total: pageviews + custom_events, per_site: per_site_usage(owned_site_ids, date_range) } end def usage_cycle(team, cycle, owned_site_ids, today) do team = Teams.with_subscription(team) last_bill_date = team.subscription.last_bill_date normalized_last_bill_date = Date.shift(last_bill_date, month: Plausible.Times.diff(today, last_bill_date, :month)) date_range = case cycle do :current_cycle -> Date.range( normalized_last_bill_date, Date.shift(normalized_last_bill_date, month: 1, day: -1) ) :last_cycle -> Date.range( Date.shift(normalized_last_bill_date, month: -1), Date.shift(normalized_last_bill_date, day: -1) ) :penultimate_cycle -> Date.range( Date.shift(normalized_last_bill_date, month: -2), Date.shift(normalized_last_bill_date, day: -1, month: -1) ) end {pageviews, custom_events} = Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range) %{ date_range: date_range, pageviews: pageviews, custom_events: custom_events, total: pageviews + custom_events, per_site: per_site_usage(owned_site_ids, date_range) } end defp per_site_usage(owned_site_ids, _date_range) when length(owned_site_ids) <= 1 or length(owned_site_ids) > @max_sites_for_usage_breakdown, do: [] defp per_site_usage(owned_site_ids, date_range) do per_site_usage_breakdown = Plausible.Stats.Clickhouse.per_site_usage_breakdown(owned_site_ids, date_range) active_site_ids = Enum.map(per_site_usage_breakdown, fn {site_id, _, _} -> site_id end) inactive_site_ids = owned_site_ids -- active_site_ids domains = Map.new( Repo.all( from(s in Plausible.Site, where: s.id in ^owned_site_ids, select: {s.id, s.domain} ) ) ) usage_mapping = Map.new(per_site_usage_breakdown, fn {site_id, pageviews, custom_events} -> {site_id, %{pageviews: pageviews, custom_events: custom_events}} end) Enum.map(active_site_ids ++ inactive_site_ids, fn site_id -> pageviews = usage_mapping[site_id][:pageviews] || 0 custom_events = usage_mapping[site_id][:custom_events] || 0 %{ domain: Map.get(domains, site_id), pageviews: pageviews, custom_events: custom_events, total: pageviews + custom_events } end) end @spec features_usage(Teams.Team.t() | nil, list() | nil) :: [atom()] @doc """ Given only a team, this function returns the features used across all the sites this team owns + StatsAPI if any team user has a configured Stats API key. Given a team, and a list of site_ids, returns the features used by those sites instead + StatsAPI if any user in the team has a configured Stats API key. The team can also be passed as `nil`, in which case we will never return Stats API as a used feature. """ def features_usage(team, site_ids \\ nil) on_ee do def features_usage(nil, nil), do: [] def features_usage(%Teams.Team{} = team, nil) do owned_site_ids = Teams.owned_sites_ids(team) features_usage(team, owned_site_ids) end def features_usage(%Teams.Team{} = team, owned_site_ids) when is_list(owned_site_ids) do site_scoped_feature_usage = features_usage(nil, owned_site_ids) stats_api_used? = Repo.exists?( from tm in Plausible.Teams.Membership, as: :team_membership, where: tm.team_id == ^team.id, where: exists( from ak in Plausible.Auth.ApiKey, where: ak.user_id == parent_as(:team_membership).user_id ) ) site_scoped_feature_usage = if stats_api_used? do site_scoped_feature_usage ++ [Feature.StatsAPI] else site_scoped_feature_usage end sites_api_used? = Repo.exists?( from tm in Plausible.Teams.Membership, as: :team_membership, where: tm.team_id == ^team.id, where: exists( from ak in Plausible.Auth.ApiKey, where: ak.user_id == parent_as(:team_membership).user_id, where: "sites:provision:*" in ak.scopes ) ) site_scoped_feature_usage = if sites_api_used? do site_scoped_feature_usage ++ [SitesAPI] else site_scoped_feature_usage end sso_used? = case Plausible.Auth.SSO.get_integration_for(team) do {:ok, _} -> true _ -> false end if sso_used? do site_scoped_feature_usage ++ [SSO] else site_scoped_feature_usage end end def features_usage(nil, site_ids) when is_list(site_ids) do shared_links_usage_q = from l in Plausible.Site.SharedLink, where: l.site_id in ^site_ids and l.name not in ^Plausible.Sites.shared_link_special_names() props_usage_q = from s in Plausible.Site, where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props) funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids revenue_goals_usage_q = from g in Plausible.Goal, where: g.site_id in ^site_ids and not is_nil(g.currency) site_segments_usage_q = from s in Plausible.Segments.Segment, where: s.site_id in ^site_ids and s.type == :site site_annotations_usage_q = from a in Plausible.Annotations.Annotation, where: a.site_id in ^site_ids and a.type == :site [ {Feature.SharedLinks, shared_links_usage_q}, {Feature.Props, props_usage_q}, {Feature.Funnels, funnels_usage_q}, {Feature.RevenueGoals, revenue_goals_usage_q}, {Feature.SiteSegments, site_segments_usage_q}, {Feature.SiteAnnotations, site_annotations_usage_q} ] |> Enum.reduce([], fn {feature, query}, acc -> if Repo.exists?(query), do: acc ++ [feature], else: acc end) end else def features_usage(_team, _site_ids), do: [] end defp query_team_member_emails(team, pending_ownership_site_ids, exclude_emails) do pending_owner_memberships_q = from s in Plausible.Site.regular(), inner_join: t in assoc(s, :team), inner_join: tm in assoc(t, :team_memberships), inner_join: u in assoc(tm, :user), where: s.id in ^pending_ownership_site_ids, where: tm.role == :owner, where: u.email not in ^exclude_emails, select: %{email: u.email} pending_memberships_q = from tm in Teams.Membership, inner_join: u in assoc(tm, :user), left_join: gm in assoc(tm, :guest_memberships), where: gm.site_id in ^pending_ownership_site_ids, where: u.email not in ^exclude_emails, select: %{email: u.email} pending_invitations_q = from ti in Teams.Invitation, inner_join: gi in assoc(ti, :guest_invitations), where: gi.site_id in ^pending_ownership_site_ids, where: ti.email not in ^exclude_emails, select: %{email: ti.email} team_memberships_q = from tm in Teams.Membership, inner_join: u in assoc(tm, :user), where: tm.team_id == ^team.id, where: u.email not in ^exclude_emails, select: %{email: u.email} team_invitations_q = from ti in Teams.Invitation, where: ti.team_id == ^team.id, where: ti.email not in ^exclude_emails, select: %{email: ti.email} pending_memberships_q |> union(^pending_owner_memberships_q) |> union(^pending_invitations_q) |> union(^team_memberships_q) |> union(^team_invitations_q) end def allowed_features_for(nil) do @free_features end def allowed_features_for(team) do team = Teams.with_subscription(team) features = case Plans.get_subscription_plan(team.subscription) do %EnterprisePlan{features: features} -> features %Plan{features: features} -> features :free_10k -> [Props, StatsAPI, SharedLinks] nil -> if Teams.on_trial?(team) do Feature.list() -- [SitesAPI, SSO] else [] end end features |> Enum.concat(@free_features) |> Enum.uniq() end defp active_subscription_query(team) do from(s in Plausible.Billing.Subscription, where: s.team_id == ^team.id and s.status == ^Plausible.Billing.Subscription.Status.active(), order_by: [desc: s.inserted_at], limit: 1 ) end end