Count pending ownership sites towards account usage on the upgrade page (#4277)

* add notice about pending ownerships counting towards usage

* Refactor feature usage to take site_ids too

* Add option to query usage from pending ownerships

* count pending ownerships towards usage on the choose-plan page

* turn 'eligible_for_upgrade?' into a predicate

* fix variable names on ce

* fix unused alias on ce

* fix ce test
This commit is contained in:
RobertJoonas 2024-06-27 12:09:15 +03:00 committed by GitHub
parent 5c146ad30e
commit 07a54ef65c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 322 additions and 169 deletions

View File

@ -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)},

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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"""
<aside class={@class}>
<.notice title="Pending ownership transfers" class="shadow-md dark:shadow-none mt-4">
<%= @message %> To exclude pending sites from your usage, please go to
<.link href="https://plausible.io/sites" class="whitespace-nowrap font-semibold">
plausible.io/sites
</.link>
and reject the invitations.
</.notice>
</aside>
"""
else
~H""
end
end
def growth_grandfathered(assigns) do
~H"""
<div class="mt-8 space-y-3 text-sm leading-6 text-gray-600 text-justify dark:text-gray-100 xl:mt-10">

View File

@ -168,7 +168,7 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do
{checkout_disabled, disabled_message} =
cond do
not assigns.eligible_for_upgrade? ->
not Quota.eligible_for_upgrade?(assigns.usage) ->
{true, nil}
change_plan_link_text == "Currently on this plan" && not subscription_deleted ->

View File

@ -21,8 +21,19 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|> assign_new(:user, fn ->
Users.with_subscription(user_id)
end)
|> assign_new(:usage, fn %{user: user} ->
Quota.Usage.usage(user, with_features: true)
|> assign_new(:pending_ownership_site_ids, fn %{user: user} ->
user.email
|> Site.Memberships.all_pending_ownerships()
|> Enum.map(& &1.site_id)
end)
|> assign_new(:usage, fn %{
user: user,
pending_ownership_site_ids: pending_ownership_site_ids
} ->
Quota.Usage.usage(user,
with_features: true,
pending_ownership_site_ids: pending_ownership_site_ids
)
end)
|> assign_new(:owned_plan, fn %{user: %{subscription: subscription}} ->
Plans.get_regular_plan(subscription, only_non_expired: true)
@ -30,18 +41,12 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|> assign_new(:owned_tier, fn %{owned_plan: owned_plan} ->
if owned_plan, do: Map.get(owned_plan, :kind), else: nil
end)
|> assign_new(:eligible_for_upgrade?, fn %{user: user, usage: usage} ->
has_sites? = usage.sites > 0
has_pending_ownerships? = Site.Memberships.pending_ownerships?(user.email)
has_sites? or has_pending_ownerships?
end)
|> assign_new(:recommended_tier, fn %{
owned_plan: owned_plan,
eligible_for_upgrade?: eligible_for_upgrade?,
usage: usage,
user: user
} ->
if owned_plan != nil or not eligible_for_upgrade? do
if owned_plan != nil or not Quota.eligible_for_upgrade?(usage) do
nil
else
Plans.suggest_tier(user)
@ -103,9 +108,13 @@ defmodule PlausibleWeb.Live.ChoosePlan do
~H"""
<div class="bg-gray-100 dark:bg-gray-900 pt-1 pb-12 sm:pb-16 text-gray-900 dark:text-gray-100">
<div class="mx-auto max-w-7xl px-6 lg:px-20">
<Notice.pending_site_ownerships_notice
class="pb-6"
pending_ownership_count={length(@pending_ownership_site_ids)}
/>
<Notice.subscription_past_due class="pb-6" subscription={@user.subscription} />
<Notice.subscription_paused class="pb-6" subscription={@user.subscription} />
<Notice.upgrade_ineligible :if={not @eligible_for_upgrade?} />
<Notice.upgrade_ineligible :if={not Quota.eligible_for_upgrade?(@usage)} />
<div class="mx-auto max-w-4xl text-center">
<p class="text-4xl font-bold tracking-tight lg:text-5xl">
<%= if @owned_plan,

View File

@ -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

View File

@ -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 =~ "<b>2</b>"
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