diff --git a/lib/plausible/billing/qouta/quota.ex b/lib/plausible/billing/qouta/quota.ex index 9ee576047..cf129fbe8 100644 --- a/lib/plausible/billing/qouta/quota.ex +++ b/lib/plausible/billing/qouta/quota.ex @@ -56,6 +56,8 @@ defmodule Plausible.Billing.Quota do def ensure_within_plan_limits(_, _, _), do: :ok + def eligible_for_upgrade?(usage), do: usage.sites > 0 + defp exceeded_limits(usage, plan, opts) do for {limit, exceeded?} <- [ {:team_member_limit, not within_limit?(usage.team_members, plan.team_member_limit)}, diff --git a/lib/plausible/billing/qouta/usage.ex b/lib/plausible/billing/qouta/usage.ex index c0bfae7c9..4346f86a0 100644 --- a/lib/plausible/billing/qouta/usage.ex +++ b/lib/plausible/billing/qouta/usage.ex @@ -6,8 +6,7 @@ defmodule Plausible.Billing.Quota.Usage do alias Plausible.Users alias Plausible.Auth.User alias Plausible.Site - alias Plausible.Billing.{Subscriptions} - alias Plausible.Billing.Feature.{RevenueGoals, Funnels, Props, StatsAPI} + alias Plausible.Billing.{Subscriptions, Feature} @type cycles_usage() :: %{cycle() => usage_cycle()} @@ -22,16 +21,36 @@ defmodule Plausible.Billing.Quota.Usage do total: non_neg_integer() } + @doc """ + Returns a full usage report for the user. + + ### 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 user 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 usage(user, opts \\ []) do + owned_site_ids = Plausible.Sites.owned_site_ids(user) + pending_ownership_site_ids = Keyword.get(opts, :pending_ownership_site_ids, []) + all_site_ids = Enum.uniq(owned_site_ids ++ pending_ownership_site_ids) + basic_usage = %{ - monthly_pageviews: monthly_pageview_usage(user), - team_members: team_member_usage(user), - sites: site_usage(user) + monthly_pageviews: monthly_pageview_usage(user, all_site_ids), + team_members: + team_member_usage(user, pending_ownership_site_ids: pending_ownership_site_ids), + sites: length(all_site_ids) } if Keyword.get(opts, :with_features) == true do basic_usage - |> Map.put(:features, features_usage(user)) + |> Map.put(:features, features_usage(user, all_site_ids)) else basic_usage end @@ -151,116 +170,120 @@ defmodule Plausible.Billing.Quota.Usage do } end - @spec team_member_usage(User.t()) :: integer() + @spec team_member_usage(User.t(), Keyword.t()) :: non_neg_integer() @doc """ Returns the total count of team members associated with the user's sites. * The given user (i.e. the owner) is not counted as a team member. - * Pending invitations are counted as team members even before accepted. + * 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. - * Specific e-mails can be excluded from the count, so that where necessary, - we can ensure inviting the same person(s) to more than 1 sites is allowed - """ - def team_member_usage(user, opts \\ []) do - {:ok, opts} = Keyword.validate(opts, site: nil, exclude_emails: []) + ### Options - user - |> team_member_usage_query(opts) + * `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(user, opts \\ []) + + def team_member_usage(%User{} = user, opts) do + exclude_emails = Keyword.get(opts, :exclude_emails, []) ++ [user.email] + + q = + user + |> Plausible.Sites.owned_site_ids() + |> query_team_member_emails() + + q = + case Keyword.get(opts, :pending_ownership_site_ids) do + [_ | _] = site_ids -> union(q, ^query_team_member_emails(site_ids)) + _ -> q + end + + from(u in subquery(q), + where: u.email not in ^exclude_emails, + distinct: u.email + ) |> Plausible.Repo.aggregate(:count) end - defp team_member_usage_query(user, opts) do - owned_sites_query = owned_sites_query(user) - - excluded_emails = - opts - |> Keyword.get(:exclude_emails, []) - |> List.wrap() - - site = opts[:site] - - owned_sites_query = - if site do - where(owned_sites_query, [os], os.site_id == ^site.id) - else - owned_sites_query - end - - team_members_query = - from os in subquery(owned_sites_query), - inner_join: sm in Site.Membership, - on: sm.site_id == os.site_id, + def query_team_member_emails(site_ids) do + memberships_q = + from sm in Site.Membership, + where: sm.site_id in ^site_ids, inner_join: u in assoc(sm, :user), - where: sm.role != :owner, - select: u.email + select: %{email: u.email} - team_members_query = - if excluded_emails != [] do - team_members_query |> where([..., u], u.email not in ^excluded_emails) - else - team_members_query - end - - query = + invitations_q = from i in Plausible.Auth.Invitation, - inner_join: os in subquery(owned_sites_query), - on: i.site_id == os.site_id, - where: i.role != :owner, - select: i.email, - union: ^team_members_query + where: i.site_id in ^site_ids and i.role != :owner, + select: %{email: i.email} - if excluded_emails != [] do - query - |> where([i], i.email not in ^excluded_emails) + union(memberships_q, ^invitations_q) + end + + @spec features_usage(User.t() | nil, list() | nil) :: [atom()] + @doc """ + Given only a user, this function returns the features used across all the + sites this user owns + StatsAPI if the user has a configured Stats API key. + + Given a user, and a list of site_ids, returns the features used by those + sites instead + StatsAPI if the user has a configured Stats API key. + + The user can also be passed as `nil`, in which case we will never return + Stats API as a used feature. + """ + def features_usage(user, site_ids \\ nil) + + def features_usage(%User{} = user, nil) do + site_ids = Plausible.Sites.owned_site_ids(user) + features_usage(user, site_ids) + end + + def features_usage(%User{} = user, site_ids) when is_list(site_ids) do + site_scoped_feature_usage = features_usage(nil, site_ids) + + stats_api_used? = + from(a in Plausible.Auth.ApiKey, where: a.user_id == ^user.id) + |> Plausible.Repo.exists?() + + if stats_api_used? do + site_scoped_feature_usage ++ [Feature.StatsAPI] else - query + site_scoped_feature_usage end end - @spec features_usage(User.t() | Site.t()) :: [atom()] - @doc """ - Given a user, this function returns the features used across all the sites - this user owns + StatsAPI if the user has a configured Stats API key. - - Given a site, returns the features used by the site. - """ - def features_usage(%User{} = user) do - props_usage_query = + def features_usage(nil, site_ids) when is_list(site_ids) do + props_usage_q = from s in Site, - inner_join: os in subquery(owned_sites_query(user)), - on: s.id == os.site_id, - where: fragment("cardinality(?) > 0", s.allowed_event_props) + where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props) - revenue_goals_usage = + revenue_goals_usage_q = from g in Plausible.Goal, - inner_join: os in subquery(owned_sites_query(user)), - on: g.site_id == os.site_id, - where: not is_nil(g.currency) - - stats_api_usage = from a in Plausible.Auth.ApiKey, where: a.user_id == ^user.id + where: g.site_id in ^site_ids and not is_nil(g.currency) queries = on_ee do - funnels_usage_query = - from f in "funnels", - inner_join: os in subquery(owned_sites_query(user)), - on: f.site_id == os.site_id + funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids [ - {Props, props_usage_query}, - {Funnels, funnels_usage_query}, - {RevenueGoals, revenue_goals_usage}, - {StatsAPI, stats_api_usage} + {Feature.Props, props_usage_q}, + {Feature.Funnels, funnels_usage_q}, + {Feature.RevenueGoals, revenue_goals_usage_q} ] else [ - {Props, props_usage_query}, - {RevenueGoals, revenue_goals_usage}, - {StatsAPI, stats_api_usage} + {Feature.Props, props_usage_q}, + {Feature.RevenueGoals, revenue_goals_usage_q} ] end @@ -268,34 +291,4 @@ defmodule Plausible.Billing.Quota.Usage do if Plausible.Repo.exists?(query), do: acc ++ [feature], else: acc end) end - - def features_usage(%Site{} = site) do - props_exist = is_list(site.allowed_event_props) && site.allowed_event_props != [] - - funnels_exist = - on_ee do - Plausible.Repo.exists?(from f in Plausible.Funnel, where: f.site_id == ^site.id) - else - false - end - - revenue_goals_exist = - Plausible.Repo.exists?( - from g in Plausible.Goal, where: g.site_id == ^site.id and not is_nil(g.currency) - ) - - used_features = [ - {Props, props_exist}, - {Funnels, funnels_exist}, - {RevenueGoals, revenue_goals_exist} - ] - - for {f_mod, used?} <- used_features, used?, f_mod.enabled?(site), do: f_mod - end - - defp owned_sites_query(user) do - from sm in Site.Membership, - where: sm.role == :owner and sm.user_id == ^user.id, - select: %{site_id: sm.site_id} - end end diff --git a/lib/plausible/site/memberships.ex b/lib/plausible/site/memberships.ex index f277e277a..3e520b7e8 100644 --- a/lib/plausible/site/memberships.ex +++ b/lib/plausible/site/memberships.ex @@ -38,13 +38,16 @@ defmodule Plausible.Site.Memberships do ) end + @spec all_pending_ownerships(String.t()) :: list() + def all_pending_ownerships(email) do + pending_ownership_invitation_q(email) + |> Repo.all() + end + @spec pending_ownerships?(String.t()) :: boolean() def pending_ownerships?(email) do - Repo.exists?( - from(i in Plausible.Auth.Invitation, - where: i.email == ^email and i.role == ^:owner - ) - ) + pending_ownership_invitation_q(email) + |> Repo.exists?() end @spec any_or_pending?(Plausible.Auth.User.t()) :: boolean() @@ -61,4 +64,10 @@ defmodule Plausible.Site.Memberships do ) |> Repo.exists?() end + + defp pending_ownership_invitation_q(email) do + from(i in Plausible.Auth.Invitation, + where: i.email == ^email and i.role == ^:owner + ) + end end diff --git a/lib/plausible/site/memberships/create_invitation.ex b/lib/plausible/site/memberships/create_invitation.ex index 689ec8e2e..69b4cbd83 100644 --- a/lib/plausible/site/memberships/create_invitation.ex +++ b/lib/plausible/site/memberships/create_invitation.ex @@ -135,7 +135,7 @@ defmodule Plausible.Site.Memberships.CreateInvitation do defp check_team_member_limit(site, _role, invitee_email) do site = Plausible.Repo.preload(site, :owner) limit = Quota.Limits.team_member_limit(site.owner) - usage = Quota.Usage.team_member_usage(site.owner, exclude_emails: invitee_email) + usage = Quota.Usage.team_member_usage(site.owner, exclude_emails: [invitee_email]) if Quota.below_limit?(usage, limit), do: :ok, diff --git a/lib/plausible/site/memberships/invitations.ex b/lib/plausible/site/memberships/invitations.ex index 11d59fb52..a333ea129 100644 --- a/lib/plausible/site/memberships/invitations.ex +++ b/lib/plausible/site/memberships/invitations.ex @@ -75,32 +75,13 @@ defmodule Plausible.Site.Memberships.Invitations do active_subscription? = Plausible.Billing.Subscriptions.active?(new_owner.subscription) if active_subscription? && plan != :free_10k do - usage_after_transfer = %{ - monthly_pageviews: monthly_pageview_usage_after_transfer(site, new_owner), - team_members: team_member_usage_after_transfer(site, new_owner), - sites: Quota.Usage.site_usage(new_owner) + 1 - } - - Quota.ensure_within_plan_limits(usage_after_transfer, plan) + new_owner + |> Quota.Usage.usage(pending_ownership_site_ids: [site.id]) + |> Quota.ensure_within_plan_limits(plan) else {:error, :no_plan} end end - - defp team_member_usage_after_transfer(site, new_owner) do - current_usage = Quota.Usage.team_member_usage(new_owner) - site_usage = Quota.Usage.team_member_usage(site.owner, site: site) - - extra_usage = - if Plausible.Sites.is_member?(new_owner.id, site), do: 0, else: 1 - - current_usage + site_usage + extra_usage - end - - defp monthly_pageview_usage_after_transfer(site, new_owner) do - site_ids = Plausible.Sites.owned_site_ids(new_owner) ++ [site.id] - Quota.Usage.monthly_pageview_usage(new_owner, site_ids) - end else @spec ensure_can_take_ownership(Site.t(), Auth.User.t()) :: :ok def ensure_can_take_ownership(_site, _new_owner) do @@ -116,8 +97,7 @@ defmodule Plausible.Site.Memberships.Invitations do def check_feature_access(site, new_owner, false = _selfhost?) do missing_features = - site - |> Quota.Usage.features_usage() + Quota.Usage.features_usage(nil, [site.id]) |> Enum.filter(&(&1.check_availability(new_owner) != :ok)) if missing_features == [] do diff --git a/lib/plausible_web/components/billing/notice.ex b/lib/plausible_web/components/billing/notice.ex index 178d71aa9..4856b87be 100644 --- a/lib/plausible_web/components/billing/notice.ex +++ b/lib/plausible_web/components/billing/notice.ex @@ -217,6 +217,31 @@ defmodule PlausibleWeb.Components.Billing.Notice do """ end + def pending_site_ownerships_notice(%{pending_ownership_count: count} = assigns) do + if count > 0 do + message = + "Your account has been invited to become the owner of " <> + if(count == 1, do: "a site, which is", else: "#{count} sites, which are") <> + " being counted towards the usage of your account." + + assigns = assign(assigns, message: message) + + ~H""" + + """ + else + ~H"" + end + end + def growth_grandfathered(assigns) do ~H"""
<%= if @owned_plan, diff --git a/test/plausible/billing/quota_test.exs b/test/plausible/billing/quota_test.exs index f7ed49caf..3b1c87e52 100644 --- a/test/plausible/billing/quota_test.exs +++ b/test/plausible/billing/quota_test.exs @@ -244,7 +244,7 @@ defmodule Plausible.Billing.QuotaTest do end end - describe "team_member_usage/1" do + describe "team_member_usage/2" do test "returns the number of members in all of the sites the user owns" do me = insert(:user) @@ -335,7 +335,7 @@ defmodule Plausible.Billing.QuotaTest do assert Quota.Usage.team_member_usage(me) == 3 end - test "does not count ownership transfer as a team member" do + test "does not count ownership transfer as a team member by default" do me = insert(:user) site_i_own = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)]) @@ -344,6 +344,62 @@ defmodule Plausible.Billing.QuotaTest do assert Quota.Usage.team_member_usage(me) == 0 end + test "counts team members from pending ownerships when specified" do + me = insert(:user) + + user_1 = insert(:user) + user_2 = insert(:user) + + pending_ownership_site = + insert(:site, + memberships: [ + build(:site_membership, user: user_1, role: :owner), + build(:site_membership, user: user_2, role: :admin) + ] + ) + + insert(:invitation, + site: pending_ownership_site, + inviter: user_1, + email: me.email, + role: :owner + ) + + assert Quota.Usage.team_member_usage(me, + pending_ownership_site_ids: [pending_ownership_site.id] + ) == 2 + end + + test "counts invitations towards team members from pending ownership sites" do + me = insert(:user) + + user_1 = insert(:user) + user_2 = insert(:user) + + pending_ownership_site = + insert(:site, + memberships: [build(:site_membership, user: user_1, role: :owner)] + ) + + insert(:invitation, + site: pending_ownership_site, + inviter: user_1, + email: me.email, + role: :owner + ) + + insert(:invitation, + site: pending_ownership_site, + inviter: user_1, + email: user_2.email, + role: :admin + ) + + assert Quota.Usage.team_member_usage(me, + pending_ownership_site_ids: [pending_ownership_site.id] + ) == 2 + end + test "returns zero when user does not have any site" do me = insert(:user) assert Quota.Usage.team_member_usage(me) == 0 @@ -378,9 +434,9 @@ defmodule Plausible.Billing.QuotaTest do invitation = insert(:invitation, site: site_i_own, inviter: me, email: "foo@example.com") assert Quota.Usage.team_member_usage(me) == 4 - assert Quota.Usage.team_member_usage(me, exclude_emails: "arbitrary@example.com") == 4 - assert Quota.Usage.team_member_usage(me, exclude_emails: member.email) == 3 - assert Quota.Usage.team_member_usage(me, exclude_emails: invitation.email) == 3 + assert Quota.Usage.team_member_usage(me, exclude_emails: ["arbitrary@example.com"]) == 4 + assert Quota.Usage.team_member_usage(me, exclude_emails: [member.email]) == 3 + assert Quota.Usage.team_member_usage(me, exclude_emails: [invitation.email]) == 3 assert Quota.Usage.team_member_usage(me, exclude_emails: [member.email, invitation.email]) == 2 @@ -441,10 +497,10 @@ defmodule Plausible.Billing.QuotaTest do end end - describe "features_usage/1" do + describe "features_usage/2" do test "returns an empty list for a user/site who does not use any feature" do assert [] == Quota.Usage.features_usage(insert(:user)) - assert [] == Quota.Usage.features_usage(insert(:site)) + assert [] == Quota.Usage.features_usage(nil, [insert(:site).id]) end test "returns [Props] when user/site uses custom props" do @@ -456,7 +512,7 @@ defmodule Plausible.Billing.QuotaTest do memberships: [build(:site_membership, user: user, role: :owner)] ) - assert [Props] == Quota.Usage.features_usage(site) + assert [Props] == Quota.Usage.features_usage(nil, [site.id]) assert [Props] == Quota.Usage.features_usage(user) end @@ -469,7 +525,7 @@ defmodule Plausible.Billing.QuotaTest do steps = Enum.map(goals, &%{"goal_id" => &1.id}) Plausible.Funnels.create(site, "dummy", steps) - assert [Funnels] == Quota.Usage.features_usage(site) + assert [Funnels] == Quota.Usage.features_usage(nil, [site.id]) assert [Funnels] == Quota.Usage.features_usage(user) end @@ -478,7 +534,7 @@ defmodule Plausible.Billing.QuotaTest do site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)]) insert(:goal, currency: :USD, site: site, event_name: "Purchase") - assert [RevenueGoals] == Quota.Usage.features_usage(site) + assert [RevenueGoals] == Quota.Usage.features_usage(nil, [site.id]) assert [RevenueGoals] == Quota.Usage.features_usage(user) end end @@ -490,9 +546,20 @@ defmodule Plausible.Billing.QuotaTest do assert [StatsAPI] == Quota.Usage.features_usage(user) end + test "returns feature usage based on a user and a custom list of site_ids" do + user = insert(:user) + insert(:api_key, user: user) + + site_using_props = insert(:site, allowed_event_props: ["dummy"]) + + site_ids = [site_using_props.id] + assert [Props, StatsAPI] == Quota.Usage.features_usage(user, site_ids) + end + on_ee do - test "returns multiple features" do + test "returns multiple features used by the user" do user = insert(:user) + insert(:api_key, user: user) site = insert(:site, @@ -506,8 +573,7 @@ defmodule Plausible.Billing.QuotaTest do steps = Enum.map(goals, &%{"goal_id" => &1.id}) Plausible.Funnels.create(site, "dummy", steps) - assert [Props, Funnels, RevenueGoals] == Quota.Usage.features_usage(site) - assert [Props, Funnels, RevenueGoals] == Quota.Usage.features_usage(user) + assert [Props, Funnels, RevenueGoals, StatsAPI] == Quota.Usage.features_usage(user) end end diff --git a/test/plausible_web/live/choose_plan_test.exs b/test/plausible_web/live/choose_plan_test.exs index f613474d3..3104c7546 100644 --- a/test/plausible_web/live/choose_plan_test.exs +++ b/test/plausible_web/live/choose_plan_test.exs @@ -375,6 +375,75 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do assert doc =~ "billable pageviews in the last billing cycle" end + test "renders notice about pending ownerships and counts their usage", %{ + conn: conn, + user: user, + site: site + } do + yesterday = NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day) + + populate_stats(site, [ + build(:pageview, timestamp: yesterday) + ]) + + another_user = insert(:user) + + pending_site = + insert(:site, + memberships: [ + build(:site_membership, role: :owner, user: another_user), + build(:site_membership, role: :admin, user: build(:user)), + build(:site_membership, role: :viewer, user: build(:user)), + build(:site_membership, role: :viewer, user: build(:user)) + ] + ) + + populate_stats(pending_site, [ + build(:pageview, timestamp: yesterday) + ]) + + insert(:invitation, + site: pending_site, + inviter: another_user, + email: user.email, + role: :owner + ) + + {:ok, _lv, doc} = get_liveview(conn) + + assert doc =~ "Your account has been invited to become the owner of a site" + + assert text_of_element(doc, @growth_plan_tooltip) == + "Your usage exceeds the following limit(s): Team member limit" + + assert doc =~ "2" + assert doc =~ "billable pageviews in the last billing cycle" + end + + test "warns about losing access to a feature used by a pending ownership site", %{ + conn: conn, + user: user + } do + another_user = insert(:user) + pending_site = insert(:site, members: [another_user]) + + Plausible.Props.allow(pending_site, ["author"]) + + insert(:invitation, + site: pending_site, + inviter: another_user, + email: user.email, + role: :owner + ) + + {:ok, _lv, doc} = get_liveview(conn) + + assert doc =~ "Your account has been invited to become the owner of a site" + + assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ + "if (confirm(\"This plan does not support Custom Properties, which you are currently using. Please note that by subscribing to this plan you will lose access to this feature.\")) {window.location = " + end + test "gets default selected interval from current subscription plan", %{conn: conn} do {:ok, _lv, doc} = get_liveview(conn) assert class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class