Move limit enforcement to accepting site ownership transfer (#3612)

* Move limit enforcement to accepting site ownerhsip transfer

* enforce pageview limit on ownership transfer accept

* Refactor plan limit check logic

* Extract `ensure_can_take_ownership` to `Invitations` context and refactor

* Improve styling of exceeded limits notice in invitation dialog and disable button

* styling improvements to notice

* make transfer_ownership return transfer to self error

* do not allow transferring to user without active subscription WIP

* Add missing typespec and improve existing ones

* Fix formatting

* Explicitly label direct match on function argument for clarity

* Slightly refactor `CreateInvitation.bulk_transfer_ownership_direct`

* Exclude quota enforcement tests from small build test suite

* Remove unused return type from `invite_error()` union type

* Do not block plan upgrade when there's pending ownership transfer

* Don't block and only warn about missing features on transfer

* Remove `x-init` attribute used for debugging

* Add tests for `Quota.monthly_pageview_usage/2`

* Test and improve site admin ownership transfer actions

* Extend tests for `AcceptInvitation.transfer_ownership`

* Test transfer ownership controller level accept action error cases

* Test choosing plan by user without sites but with a pending ownership transfer

* Test invitation x-data in sites LV

* Remove sitelocker trigger in invitation acceptance code and simplify logic

* Add Quota test for `user.allow_next_upgrade_override` being set

* ignore pageview limit only when subscribing to plan

* Use sandbox Paddle instance for staging

* Use sandbox paddle key for staging and dev

---------

Co-authored-by: Robert Joonas <robertjoonas16@gmail.com>
This commit is contained in:
Adrian Gruntkowski 2023-12-20 15:56:49 +01:00 committed by GitHub
parent 1678fa10f4
commit 9d97dc1912
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1254 additions and 615 deletions

View File

@ -3,6 +3,7 @@ defmodule Plausible.Billing do
use Plausible.Repo
require Plausible.Billing.Subscription.Status
alias Plausible.Billing.{Subscription, Plans, Quota}
alias Plausible.Auth.User
@spec active_subscription_for(integer()) :: Subscription.t() | nil
def active_subscription_for(user_id) do
@ -42,7 +43,14 @@ defmodule Plausible.Billing do
subscription = active_subscription_for(user.id)
plan = Plans.find(new_plan_id)
with :ok <- Quota.ensure_can_subscribe_to_plan(user, plan),
limit_checking_opts =
if user.allow_next_upgrade_override do
[ignore_pageview_limit: true]
else
[]
end
with :ok <- Quota.ensure_within_plan_limits(user, plan, limit_checking_opts),
do: do_change_plan(subscription, new_plan_id)
end
@ -81,10 +89,10 @@ defmodule Plausible.Billing do
end
end
@spec check_needs_to_upgrade(Plausible.Auth.User.t()) ::
@spec check_needs_to_upgrade(User.t()) ::
{:needs_to_upgrade, :no_trial | :no_active_subscription | :grace_period_ended}
| :no_upgrade_needed
def check_needs_to_upgrade(%Plausible.Auth.User{trial_expiry_date: nil}) do
def check_needs_to_upgrade(%User{trial_expiry_date: nil}) do
{:needs_to_upgrade, :no_trial}
end
@ -111,7 +119,7 @@ defmodule Plausible.Billing do
def subscription_is_active?(nil), do: false
on_full_build do
def on_trial?(%Plausible.Auth.User{trial_expiry_date: nil}), do: false
def on_trial?(%User{trial_expiry_date: nil}), do: false
def on_trial?(user) do
user = Plausible.Users.with_subscription(user)
@ -130,7 +138,7 @@ defmodule Plausible.Billing do
if present?(params["passthrough"]) do
params
else
user = Repo.get_by(Plausible.Auth.User, email: params["email"])
user = Repo.get_by(User, email: params["email"])
Map.put(params, "passthrough", user && user.id)
end
@ -223,7 +231,7 @@ defmodule Plausible.Billing do
defp present?(nil), do: false
defp present?(_), do: true
defp maybe_remove_grace_period(%Plausible.Auth.User{} = user) do
defp maybe_remove_grace_period(%User{} = user) do
alias Plausible.Auth.GracePeriod
case user.grace_period do
@ -251,7 +259,7 @@ defmodule Plausible.Billing do
def paddle_api(), do: Application.fetch_env!(:plausible, :paddle_api)
def cancelled_subscription_notice_dismiss_id(%Plausible.Auth.User{} = user) do
def cancelled_subscription_notice_dismiss_id(%User{} = user) do
"subscription_cancelled__#{user.id}"
end
@ -265,7 +273,7 @@ defmodule Plausible.Billing do
defp after_subscription_update(subscription) do
user =
Plausible.Auth.User
User
|> Repo.get!(subscription.user_id)
|> Map.put(:subscription, subscription)

View File

@ -70,6 +70,9 @@ defmodule Plausible.Billing.Feature do
Plausible.Billing.Feature.RevenueGoals
]
# Generate a union type for features
@type t() :: unquote(Enum.reduce(@features, &{:|, [], [&1, &2]}))
@doc """
Lists all available feature modules.
"""

View File

@ -148,16 +148,18 @@ defmodule Plausible.Billing.PaddleApi do
end
def checkout_domain() do
case Application.get_env(:plausible, :environment) do
"dev" -> "https://sandbox-checkout.paddle.com"
_ -> "https://checkout.paddle.com"
if Application.get_env(:plausible, :environment) in ["dev", "staging"] do
"https://sandbox-checkout.paddle.com"
else
"https://checkout.paddle.com"
end
end
def vendors_domain() do
case Application.get_env(:plausible, :environment) do
"dev" -> "https://sandbox-vendors.paddle.com"
_ -> "https://vendors.paddle.com"
if Application.get_env(:plausible, :environment) in ["dev", "staging"] do
"https://sandbox-vendors.paddle.com"
else
"https://vendors.paddle.com"
end
end

View File

@ -41,7 +41,7 @@ defmodule Plausible.Billing.Plans do
owned_plan = get_regular_plan(user.subscription)
cond do
Application.get_env(:plausible, :environment) == "dev" -> @sandbox_plans
Application.get_env(:plausible, :environment) in ["dev", "staging"] -> @sandbox_plans
is_nil(owned_plan) -> @plans_v4
user.subscription && Subscriptions.expired?(user.subscription) -> @plans_v4
owned_plan.kind == :business -> @plans_v4
@ -58,7 +58,7 @@ defmodule Plausible.Billing.Plans do
owned_plan = get_regular_plan(user.subscription)
cond do
Application.get_env(:plausible, :environment) == "dev" -> @sandbox_plans
Application.get_env(:plausible, :environment) in ["dev", "staging"] -> @sandbox_plans
user.subscription && Subscriptions.expired?(user.subscription) -> @plans_v4
owned_plan && owned_plan.generation < 4 -> @plans_v3
true -> @plans_v4
@ -246,6 +246,10 @@ defmodule Plausible.Billing.Plans do
end
defp sandbox_plans() do
if Application.get_env(:plausible, :environment) == "dev", do: @sandbox_plans, else: []
if Application.get_env(:plausible, :environment) in ["dev", "staging"] do
@sandbox_plans
else
[]
end
end
end

View File

@ -10,6 +10,21 @@ defmodule Plausible.Billing.Quota do
alias Plausible.Billing.{Plan, Plans, Subscription, EnterprisePlan, Feature}
alias Plausible.Billing.Feature.{Goals, RevenueGoals, Funnels, Props, StatsAPI}
@type limit() :: :site_limit | :pageview_limit | :team_member_limit
@type over_limits_error() :: {:over_plan_limits, [limit()]}
@type monthly_pageview_usage() :: %{period() => usage_cycle()}
@type period :: :last_30_days | :current_cycle | :last_cycle | :penultimate_cycle
@type usage_cycle :: %{
date_range: Date.Range.t(),
pageviews: non_neg_integer(),
custom_events: non_neg_integer(),
total: non_neg_integer()
}
def usage(user, opts \\ []) do
basic_usage = %{
monthly_pageviews: monthly_pageview_usage(user),
@ -129,33 +144,49 @@ defmodule Plausible.Billing.Quota do
end
end
@type monthly_pageview_usage() :: %{period() => usage_cycle()}
@doc """
Queries the ClickHouse database for the monthly pageview usage. If the given user's
subscription is `active`, `past_due`, or a `deleted` (but not yet expired), a map
with the following structure is returned:
@type period :: :last_30_days | :current_cycle | :last_cycle | :penultimate_cycle
```elixir
%{
current_cycle: usage_cycle(),
last_cycle: usage_cycle(),
penultimate_cycle: usage_cycle()
}
```
@type usage_cycle :: %{
date_range: Date.Range.t(),
pageviews: non_neg_integer(),
custom_events: non_neg_integer(),
total: non_neg_integer()
}
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:
@spec monthly_pageview_usage(User.t()) :: monthly_pageview_usage()
```elixir
%{last_30_days: usage_cycle()}
```
def monthly_pageview_usage(user) do
Given only a user as input, the usage is queried from across all the sites that the
user owns. Alternatively, given an optional argument of `site_ids`, the usage from
across all those sites is queried instead.
"""
@spec monthly_pageview_usage(User.t(), list() | nil) :: monthly_pageview_usage()
def monthly_pageview_usage(user, site_ids \\ nil)
def monthly_pageview_usage(user, nil) do
monthly_pageview_usage(user, Plausible.Sites.owned_site_ids(user))
end
def monthly_pageview_usage(user, site_ids) do
active_subscription? = Plausible.Billing.subscription_is_active?(user.subscription)
if active_subscription? && user.subscription.last_bill_date do
owned_site_ids = Plausible.Sites.owned_site_ids(user)
[:current_cycle, :last_cycle, :penultimate_cycle]
|> Task.async_stream(fn cycle ->
%{cycle => usage_cycle(user, cycle, owned_site_ids)}
%{cycle => usage_cycle(user, cycle, site_ids)}
end)
|> Enum.map(fn {:ok, cycle_usage} -> cycle_usage end)
|> Enum.reduce(%{}, &Map.merge/2)
else
%{last_30_days: usage_cycle(user, :last_30_days)}
%{last_30_days: usage_cycle(user, :last_30_days, site_ids)}
end
end
@ -334,48 +365,63 @@ defmodule Plausible.Billing.Quota do
for {f_mod, used?} <- used_features, used?, f_mod.enabled?(site), do: f_mod
end
def ensure_can_subscribe_to_plan(user, plan, usage \\ nil)
@doc """
Ensures that the given user (or the usage map) is within the limits
of the given plan.
def ensure_can_subscribe_to_plan(%User{} = user, %Plan{} = plan, usage) do
usage = if usage, do: usage, else: usage(user)
An `opts` argument can be passed with `ignore_pageview_limit: true`
which bypasses the pageview limit check and returns `:ok` as long as
the other limits are not exceeded.
"""
@spec ensure_within_plan_limits(User.t() | map(), struct() | atom() | nil, Keyword.t()) ::
:ok | {:error, over_limits_error()}
def ensure_within_plan_limits(user_or_usage, plan, opts \\ [])
case exceeded_limits(user, plan, usage) do
def ensure_within_plan_limits(%User{} = user, %plan_mod{} = plan, opts)
when plan_mod in [Plan, EnterprisePlan] do
ensure_within_plan_limits(usage(user), plan, opts)
end
def ensure_within_plan_limits(usage, %plan_mod{} = plan, opts)
when plan_mod in [Plan, EnterprisePlan] do
case exceeded_limits(usage, plan, opts) do
[] -> :ok
exceeded_limits -> {:error, %{exceeded_limits: exceeded_limits}}
exceeded_limits -> {:error, {:over_plan_limits, exceeded_limits}}
end
end
def ensure_can_subscribe_to_plan(_, _, _), do: :ok
def ensure_within_plan_limits(_, _, _), do: :ok
defp exceeded_limits(%User{} = user, %Plan{} = plan, usage) do
defp exceeded_limits(usage, plan, opts) do
for {limit, exceeded?} <- [
{:team_member_limit, not within_limit?(usage.team_members, plan.team_member_limit)},
{:site_limit, not within_limit?(usage.sites, plan.site_limit)},
{:monthly_pageview_limit, exceeds_monthly_pageview_limit?(user, plan, usage)}
{:monthly_pageview_limit,
exceeds_monthly_pageview_limit?(usage.monthly_pageviews, plan, opts)}
],
exceeded? do
limit
end
end
defp exceeds_monthly_pageview_limit?(%User{allow_next_upgrade_override: true}, _, _) do
false
end
defp exceeds_monthly_pageview_limit?(usage, plan, opts) do
if Keyword.get(opts, :ignore_pageview_limit) do
false
else
case usage do
%{last_30_days: %{total: total}} ->
!within_limit?(total, pageview_limit_with_margin(plan))
defp exceeds_monthly_pageview_limit?(_user, plan, usage) do
case usage.monthly_pageviews do
%{last_30_days: %{total: total}} ->
!within_limit?(total, pageview_limit_with_margin(plan))
billing_cycles_usage ->
Plausible.Workers.CheckUsage.exceeds_last_two_usage_cycles?(
billing_cycles_usage,
plan.monthly_pageview_limit
)
billing_cycles_usage ->
Plausible.Workers.CheckUsage.exceeds_last_two_usage_cycles?(
billing_cycles_usage,
plan.monthly_pageview_limit
)
end
end
end
defp pageview_limit_with_margin(%Plan{monthly_pageview_limit: limit}) do
defp pageview_limit_with_margin(%{monthly_pageview_limit: limit}) do
allowance_margin = if limit == 10_000, do: 0.3, else: 0.15
ceil(limit * (1 + allowance_margin))
end

View File

@ -81,7 +81,7 @@ defmodule Plausible.SiteAdmin do
inviter = conn.assigns[:current_user]
if new_owner do
{:ok, _} =
result =
Plausible.Site.Memberships.bulk_create_invitation(
sites,
inviter,
@ -90,7 +90,13 @@ defmodule Plausible.SiteAdmin do
check_permissions: false
)
:ok
case result do
{:ok, _} ->
:ok
{:error, :transfer_to_self} ->
{:error, "User is already an owner of one of the sites"}
end
else
{:error, "User could not be found"}
end
@ -105,8 +111,17 @@ defmodule Plausible.SiteAdmin do
if new_owner do
case Plausible.Site.Memberships.bulk_transfer_ownership_direct(sites, new_owner) do
{:ok, _} -> :ok
{:error, :transfer_to_self} -> {:error, "User is already an owner of one of the sites"}
{:ok, _} ->
:ok
{:error, :transfer_to_self} ->
{:error, "User is already an owner of one of the sites"}
{:error, :no_plan} ->
{:error, "The new owner does not have a subscription"}
{:error, {:over_plan_limits, limits}} ->
{:error, "Plan limits exceeded for one of the sites: #{Enum.join(limits, ", ")}"}
end
else
{:error, "User could not be found"}

View File

@ -2,10 +2,15 @@ defmodule Plausible.Site.Membership do
use Ecto.Schema
import Ecto.Changeset
@roles [:owner, :admin, :viewer]
@type t() :: %__MODULE__{}
# Generate a union type for roles
@type role() :: unquote(Enum.reduce(@roles, &{:|, [], [&1, &2]}))
schema "site_memberships" do
field :role, Ecto.Enum, values: [:owner, :admin, :viewer]
field :role, Ecto.Enum, values: @roles
belongs_to :site, Plausible.Site
belongs_to :user, Plausible.Auth.User

View File

@ -38,6 +38,15 @@ defmodule Plausible.Site.Memberships do
)
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
)
)
end
@spec any_or_pending?(Plausible.Auth.User.t()) :: boolean()
def any_or_pending?(user) do
invitation_query =

View File

@ -17,62 +17,29 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
alias Ecto.Multi
alias Plausible.Auth
alias Plausible.Billing
alias Plausible.Memberships.Invitations
alias Plausible.Repo
alias Plausible.Site
alias Plausible.Site.Memberships.Invitations
require Logger
@spec transfer_ownership(Site.t(), Auth.User.t(), Keyword.t()) ::
{:ok, Site.Membership.t()} | {:error, Ecto.Changeset.t()}
def transfer_ownership(site, user, opts \\ []) do
selfhost? = Keyword.get(opts, :selfhost?, small_build?())
membership = get_or_create_owner_membership(site, user)
multi = add_and_transfer_ownership(site, membership, user, selfhost?)
@spec transfer_ownership(Site.t(), Auth.User.t()) ::
{:ok, Site.Membership.t()}
| {:error,
Billing.Quota.over_limits_error()
| Ecto.Changeset.t()
| :transfer_to_self
| :no_plan}
def transfer_ownership(site, user) do
site = Repo.preload(site, :owner)
case Repo.transaction(multi) do
{:ok, changes} ->
if changes[:site_locker] == {:locked, :grace_period_ended_now} do
user = Plausible.Users.with_subscription(changes.user)
Billing.SiteLocker.send_grace_period_end_email(user)
end
membership = Repo.preload(changes.membership, [:site, :user])
{:ok, membership}
{:error, _operation, error, _changes} ->
{:error, error}
end
end
@spec accept_invitation(String.t(), Auth.User.t(), Keyword.t()) ::
{:ok, Site.Membership.t()} | {:error, :invitation_not_found | Ecto.Changeset.t()}
def accept_invitation(invitation_id, user, opts \\ []) do
selfhost? = Keyword.get(opts, :selfhost?, small_build?())
with {:ok, invitation} <- Invitations.find_for_user(invitation_id, user) do
membership = get_or_create_membership(invitation, user)
multi =
if invitation.role == :owner do
invitation.site
|> add_and_transfer_ownership(membership, user, selfhost?)
|> Multi.delete(:invitation, invitation)
else
add(invitation, membership, user)
end
with :ok <- Invitations.ensure_transfer_valid(site, user, :owner),
:ok <- Invitations.ensure_can_take_ownership(site, user) do
membership = get_or_create_owner_membership(site, user)
multi = add_and_transfer_ownership(site, membership, user)
case Repo.transaction(multi) do
{:ok, changes} ->
if changes[:site_locker] == {:locked, :grace_period_ended_now} do
user = Plausible.Users.with_subscription(changes.user)
Billing.SiteLocker.send_grace_period_end_email(user)
end
notify_invitation_accepted(invitation)
membership = Repo.preload(changes.membership, [:site, :user])
{:ok, membership}
@ -83,22 +50,63 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end
end
defp add_and_transfer_ownership(site, membership, user, selfhost?) do
multi =
Multi.new()
|> downgrade_previous_owner(site, user)
|> maybe_end_trial_of_new_owner(user, selfhost?)
|> Multi.insert_or_update(:membership, membership)
if selfhost? do
multi
else
Multi.run(multi, :site_locker, fn _, %{user: updated_user} ->
{:ok, Billing.SiteLocker.update_sites_for(updated_user, send_email?: false)}
end)
@spec accept_invitation(String.t(), Auth.User.t()) ::
{:ok, Site.Membership.t()}
| {:error,
:invitation_not_found
| Billing.Quota.over_limits_error()
| Ecto.Changeset.t()
| :no_plan}
def accept_invitation(invitation_id, user) do
with {:ok, invitation} <- Invitations.find_for_user(invitation_id, user) do
if invitation.role == :owner do
do_accept_ownership_transfer(invitation, user)
else
do_accept_invitation(invitation, user)
end
end
end
defp do_accept_ownership_transfer(invitation, user) do
membership = get_or_create_membership(invitation, user)
site = Repo.preload(invitation.site, :owner)
with :ok <- Invitations.ensure_can_take_ownership(site, user) do
site
|> add_and_transfer_ownership(membership, user)
|> Multi.delete(:invitation, invitation)
|> finalize_invitation(invitation)
end
end
defp do_accept_invitation(invitation, user) do
membership = get_or_create_membership(invitation, user)
invitation
|> add(membership, user)
|> finalize_invitation(invitation)
end
defp finalize_invitation(multi, invitation) do
case Repo.transaction(multi) do
{:ok, changes} ->
notify_invitation_accepted(invitation)
membership = Repo.preload(changes.membership, [:site, :user])
{:ok, membership}
{:error, _operation, error, _changes} ->
{:error, error}
end
end
defp add_and_transfer_ownership(site, membership, user) do
Multi.new()
|> downgrade_previous_owner(site, user)
|> Multi.insert_or_update(:membership, membership)
end
# If there's an existing membership, we DO NOT change the role
# to avoid accidental role downgrade.
defp add(invitation, membership, _user) do
@ -164,28 +172,6 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do
end
end
# If the new owner is the same as the old owner, it's a no-op
defp maybe_end_trial_of_new_owner(multi, new_owner, selfhost?) do
new_owner_id = new_owner.id
cond do
selfhost? ->
Multi.put(multi, :user, new_owner)
Billing.on_trial?(new_owner) or is_nil(new_owner.trial_expiry_date) ->
Multi.update(multi, :user, fn
%{previous_owner_membership: %{user_id: ^new_owner_id}} ->
Ecto.Changeset.change(new_owner)
_ ->
Auth.User.end_trial(new_owner)
end)
true ->
Multi.put(multi, :user, new_owner)
end
end
defp notify_invitation_accepted(%Auth.Invitation{role: :owner} = invitation) do
PlausibleWeb.Email.ownership_transfer_accepted(invitation)
|> Plausible.Mailer.send()

View File

@ -6,6 +6,7 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
alias Plausible.Auth.{User, Invitation}
alias Plausible.{Site, Sites, Site.Membership}
alias Plausible.Site.Memberships.Invitations
alias Plausible.Billing.Quota
import Ecto.Query
@ -13,9 +14,9 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
Ecto.Changeset.t()
| :already_a_member
| :transfer_to_self
| :no_plan
| {:over_limit, non_neg_integer()}
| :forbidden
| :upgrade_required
@spec create_invitation(Site.t(), User.t(), String.t(), atom()) ::
{:ok, Invitation.t()} | {:error, invite_error()}
@ -37,16 +38,21 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
end
@spec bulk_transfer_ownership_direct([Site.t()], User.t()) ::
{:ok, [Membership.t()]} | {:error, invite_error()}
{:ok, [Membership.t()]}
| {:error,
invite_error()
| Quota.over_limits_error()}
def bulk_transfer_ownership_direct(sites, new_owner) do
Plausible.Repo.transaction(fn ->
for site <- sites do
with site <- Plausible.Repo.preload(site, :owner),
:ok <- ensure_transfer_valid(site, new_owner, :owner),
{:ok, membership} <- Site.Memberships.transfer_ownership(site, new_owner) do
membership
else
{:error, error} -> Plausible.Repo.rollback(error)
site = Plausible.Repo.preload(site, :owner)
case Site.Memberships.transfer_ownership(site, new_owner) do
{:ok, membership} ->
membership
{:error, error} ->
Plausible.Repo.rollback(error)
end
end
end)
@ -69,7 +75,7 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
:ok <- check_invitation_permissions(site, inviter, role, opts),
:ok <- check_team_member_limit(site, role),
invitee <- Plausible.Auth.find_user_by(email: invitee_email),
:ok <- ensure_transfer_valid(site, invitee, role),
:ok <- Invitations.ensure_transfer_valid(site, invitee, role),
:ok <- ensure_new_membership(site, invitee, role),
%Ecto.Changeset{} = changeset <- Invitation.new(attrs),
{:ok, invitation} <- Plausible.Repo.insert(changeset) do
@ -110,43 +116,6 @@ defmodule Plausible.Site.Memberships.CreateInvitation do
Plausible.Mailer.send(email)
end
defp within_team_member_limit_after_transfer?(site, new_owner) do
limit = Quota.team_member_limit(new_owner)
current_usage = Quota.team_member_usage(new_owner)
site_usage = Plausible.Repo.aggregate(Quota.team_member_usage_query(site.owner, site), :count)
usage_after_transfer = current_usage + site_usage + 1
Quota.within_limit?(usage_after_transfer, limit)
end
defp within_site_limit_after_transfer?(new_owner) do
limit = Quota.site_limit(new_owner)
usage_after_transfer = Quota.site_usage(new_owner) + 1
Quota.within_limit?(usage_after_transfer, limit)
end
defp has_access_to_site_features?(site, new_owner) do
site
|> Plausible.Billing.Quota.features_usage()
|> Enum.all?(&(&1.check_availability(new_owner) == :ok))
end
defp ensure_transfer_valid(%Site{} = site, %User{} = new_owner, :owner) do
cond do
Sites.role(new_owner.id, site) == :owner -> {:error, :transfer_to_self}
not within_team_member_limit_after_transfer?(site, new_owner) -> {:error, :upgrade_required}
not within_site_limit_after_transfer?(new_owner) -> {:error, :upgrade_required}
not has_access_to_site_features?(site, new_owner) -> {:error, :upgrade_required}
true -> :ok
end
end
defp ensure_transfer_valid(_site, _invitee, _role) do
:ok
end
defp ensure_new_membership(_site, _invitee, :owner) do
:ok
end

View File

@ -1,10 +1,17 @@
defmodule Plausible.Site.Memberships.Invitations do
@moduledoc false
use Plausible
import Ecto.Query, only: [from: 2]
alias Plausible.Site
alias Plausible.Auth
alias Plausible.Repo
alias Plausible.Billing.Quota
alias Plausible.Billing.Feature
@type missing_features_error() :: {:missing_features, [Feature.t()]}
@spec find_for_user(String.t(), Auth.User.t()) ::
{:ok, Auth.Invitation.t()} | {:error, :invitation_not_found}
@ -42,4 +49,81 @@ defmodule Plausible.Site.Memberships.Invitations do
:ok
end
@spec ensure_transfer_valid(Site.t(), Auth.User.t() | nil, Site.Membership.role()) ::
:ok | {:error, :transfer_to_self}
def ensure_transfer_valid(%Site{} = site, %Auth.User{} = new_owner, :owner) do
if Plausible.Sites.role(new_owner.id, site) == :owner do
{:error, :transfer_to_self}
else
:ok
end
end
def ensure_transfer_valid(_site, _invitee, _role) do
:ok
end
on_full_build do
@spec ensure_can_take_ownership(Site.t(), Auth.User.t()) ::
:ok | {:error, Quota.over_limits_error() | :no_plan}
def ensure_can_take_ownership(site, new_owner) do
site = Repo.preload(site, :owner)
new_owner = Plausible.Users.with_subscription(new_owner)
plan = Plausible.Billing.Plans.get_subscription_plan(new_owner.subscription)
active_subscription? = Plausible.Billing.subscription_is_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.site_usage(new_owner) + 1
}
Quota.ensure_within_plan_limits(usage_after_transfer, plan)
else
{:error, :no_plan}
end
end
defp team_member_usage_after_transfer(site, new_owner) do
current_usage = Quota.team_member_usage(new_owner)
site_usage = Repo.aggregate(Quota.team_member_usage_query(site.owner, site), :count)
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.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
:ok
end
end
@spec check_feature_access(Site.t(), Auth.User.t(), boolean()) ::
:ok | {:error, missing_features_error()}
def check_feature_access(_site, _new_owner, true = _selfhost?) do
:ok
end
def check_feature_access(site, new_owner, false = _selfhost?) do
missing_features =
site
|> Quota.features_usage()
|> Enum.filter(&(&1.check_availability(new_owner) != :ok))
if missing_features == [] do
:ok
else
{:error, {:missing_features, missing_features}}
end
end
end

View File

@ -259,7 +259,7 @@ defmodule PlausibleWeb.Components.Billing do
~H"""
<script type="text/javascript" src="https://cdn.paddle.com/paddle/paddle.js">
</script>
<script :if={Application.get_env(:plausible, :environment) == "dev"}>
<script :if={Application.get_env(:plausible, :environment) in ["dev", "staging"]}>
Paddle.Environment.set('sandbox')
</script>
<script>

View File

@ -155,8 +155,15 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do
paddle_product_id = get_paddle_product_id(assigns.plan_to_render, assigns.selected_interval)
change_plan_link_text = change_plan_link_text(assigns)
limit_checking_opts =
if assigns.user.allow_next_upgrade_override do
[ignore_pageview_limit: true]
else
[]
end
usage_within_limits =
Quota.ensure_can_subscribe_to_plan(assigns.user, assigns.plan_to_render, assigns.usage) ==
Quota.ensure_within_plan_limits(assigns.usage, assigns.plan_to_render, limit_checking_opts) ==
:ok
subscription = assigns.user.subscription
@ -171,7 +178,7 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do
{checkout_disabled, disabled_message} =
cond do
assigns.usage.sites == 0 ->
not assigns.eligible_for_upgrade? ->
{true, nil}
change_plan_link_text == "Currently on this plan" && not subscription_deleted ->

View File

@ -29,8 +29,6 @@ defmodule PlausibleWeb.Api.PaddleController do
send_resp(conn, 404, "") |> halt
end
@paddle_key File.read!("priv/paddle.pem")
def verify_signature(conn, _opts) do
signature = Base.decode64!(conn.params["p_signature"])
@ -40,7 +38,8 @@ defmodule PlausibleWeb.Api.PaddleController do
|> List.keysort(0)
|> PhpSerializer.serialize()
[key_entry] = :public_key.pem_decode(@paddle_key)
[key_entry] = :public_key.pem_decode(get_paddle_key())
public_key = :public_key.pem_entry_decode(key_entry)
if :public_key.verify(msg, :sha, signature, public_key) do
@ -59,11 +58,22 @@ defmodule PlausibleWeb.Api.PaddleController do
|> List.keysort(0)
|> PhpSerializer.serialize()
[key_entry] = :public_key.pem_decode(@paddle_key)
[key_entry] = :public_key.pem_decode(get_paddle_key())
public_key = :public_key.pem_entry_decode(key_entry)
:public_key.verify(msg, :sha, signature, public_key)
end
@paddle_prod_key File.read!("priv/paddle.pem")
@paddle_sandbox_key File.read!("priv/paddle_sandbox.pem")
defp get_paddle_key() do
if Application.get_env(:plausible, :environment) in ["dev", "staging"] do
@paddle_sandbox_key
else
@paddle_prod_key
end
end
defp webhook_response({:ok, _}, conn, _params) do
json(conn, "")
end

View File

@ -110,8 +110,8 @@ defmodule PlausibleWeb.BillingController do
{:error, e} ->
msg =
case e do
%{exceeded_limits: exceeded_limits} ->
"Unable to subscribe to this plan because the following limits are exceeded: #{inspect(exceeded_limits)}"
{:over_plan_limits, exceeded_limits} ->
"Unable to subscribe to this plan because the following limits are exceeded: #{PlausibleWeb.TextHelpers.pretty_list(exceeded_limits)}"
%{"code" => 147} ->
# https://developer.paddle.com/api-reference/intro/api-error-codes

View File

@ -17,6 +17,19 @@ defmodule PlausibleWeb.InvitationController do
|> put_flash(:error, "Invitation missing or already accepted")
|> redirect(to: "/sites")
{:error, :no_plan} ->
conn
|> put_flash(:error, "No existing subscription")
|> redirect(to: "/sites")
{:error, {:over_plan_limits, limits}} ->
conn
|> put_flash(
:error,
"Plan limits exceeded: #{PlausibleWeb.TextHelpers.pretty_list(limits)}."
)
|> redirect(to: "/sites")
{:error, _} ->
conn
|> put_flash(:error, "Something went wrong, please try again")

View File

@ -120,16 +120,6 @@ defmodule PlausibleWeb.Site.MembershipController do
|> put_flash(:error, "Can't transfer ownership to existing owner")
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
{:error, :upgrade_required} ->
conn
|> put_flash(:ttl, :timer.seconds(5))
|> put_flash(:error_title, "Transfer error")
|> put_flash(
:error,
"The site you're trying to transfer exceeds the invitee's subscription plan. To proceed, please contact us at hello@plausible.io for further assistance."
)
|> redirect(external: Routes.site_path(conn, :settings_people, site.domain))
{:error, changeset} ->
errors = Plausible.ChangesetHelpers.traverse_errors(changeset)

View File

@ -8,6 +8,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
require Plausible.Billing.Subscription.Status
alias PlausibleWeb.Components.Billing.{PlanBox, PlanBenefits, Notice, PageviewSlider}
alias Plausible.Site
alias Plausible.Users
alias Plausible.Billing.{Plans, Plan, Quota}
@ -35,8 +36,22 @@ 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(:recommended_tier, fn %{owned_plan: owned_plan, user: user, usage: usage} ->
if owned_plan || usage.sites == 0, do: nil, else: Plans.suggest_tier(user)
|> 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?,
user: user
} ->
if owned_plan != nil or not eligible_for_upgrade? do
nil
else
Plans.suggest_tier(user)
end
end)
|> assign_new(:current_interval, fn %{user: user} ->
current_user_subscription_interval(user.subscription)
@ -97,7 +112,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
<div class="mx-auto max-w-7xl px-6 lg:px-20">
<Notice.subscription_past_due class="pb-6" subscription={@user.subscription} />
<Notice.subscription_paused class="pb-6" subscription={@user.subscription} />
<Notice.upgrade_ineligible :if={@usage.sites == 0} />
<Notice.upgrade_ineligible :if={not @eligible_for_upgrade?} />
<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

@ -5,6 +5,7 @@ defmodule PlausibleWeb.Live.Sites do
use Phoenix.LiveView, global_prefixes: ~w(x-)
use PlausibleWeb.Live.Flash
use Plausible
alias Phoenix.LiveView.JS
use Phoenix.HTML
@ -16,6 +17,8 @@ defmodule PlausibleWeb.Live.Sites do
alias Plausible.Repo
alias Plausible.Site
alias Plausible.Sites
alias Plausible.Site.Memberships.Invitations
alias PlausibleWeb.Router.Helpers, as: Routes
def mount(params, %{"current_user_id" => user_id}, socket) do
uri =
@ -53,17 +56,10 @@ defmodule PlausibleWeb.Live.Sites do
end
def render(assigns) do
invitations =
assigns.sites.entries
|> Enum.filter(&(&1.entry_type == "invitation"))
|> Enum.flat_map(& &1.invitations)
assigns = assign(assigns, :invitations, invitations)
~H"""
<.flash_messages flash={@flash} />
<div
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
x-on:keydown.escape.window="invitationOpen = false"
class="container pt-6"
>
@ -395,7 +391,7 @@ defmodule PlausibleWeb.Live.Sites do
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="bg-white dark:bg-gray-850 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
<button
x-on:click="invitationOpen = false"
@ -415,54 +411,97 @@ defmodule PlausibleWeb.Live.Sites do
id="modal-title"
>
Invitation for
<span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
<span x-text="selectedInvitation && selectedInvitation.invitation.site.domain">
</span>
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-200">
You've been invited to the
<span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
<span x-text="selectedInvitation && selectedInvitation.invitation.site.domain">
</span>
analytics dashboard as <b
class="capitalize"
x-text="selectedInvitation && selectedInvitation.role"
x-text="selectedInvitation && selectedInvitation.invitation.role"
>Admin</b>.
</p>
<div
x-show="selectedInvitation && selectedInvitation.role === 'owner'"
x-show="selectedInvitation && !(selectedInvitation.exceeded_limits || selectedInvitation.no_plan) && selectedInvitation.invitation.role === 'owner'"
class="mt-2 text-sm text-gray-500 dark:text-gray-200"
>
If you accept the ownership transfer, you will be responsible for billing going forward.
<div
:if={is_nil(@user.trial_expiry_date) and is_nil(@user.subscription)}
class="mt-4"
>
You will have to enter your card details immediately with no 30-day trial.
</div>
<div :if={Plausible.Billing.on_trial?(@user)} class="mt-4">
<Heroicons.exclamation_triangle class="w-4 h-4 inline-block text-red-500" />
Your 30-day free trial will end immediately and
<strong>you will have to enter your card details</strong>
to keep using Plausible.
</div>
</div>
</div>
</div>
</div>
<.notice
x-show="selectedInvitation && selectedInvitation.missing_features"
title="Missing features"
class="mt-4 shadow-sm dark:shadow-none"
>
<p>
The site uses <span x-text="selectedInvitation && selectedInvitation.missing_features"></span>,
which your current subscription does not support. After accepting ownership of this site,
you will not be able to access them unless you
<.styled_link
class="inline-block"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
>
upgrade to a suitable plan
</.styled_link>.
</p>
</.notice>
<.notice
x-show="selectedInvitation && selectedInvitation.exceeded_limits"
title="Exceeded limits"
class="mt-4 shadow-sm dark:shadow-none"
>
<p>
You are unable to accept the ownership of this site because doing so would exceed the
<span x-text="selectedInvitation && selectedInvitation.exceeded_limits"></span>
of your subscription.
You can review your usage in the
<.styled_link
class="inline-block"
href={Routes.auth_path(PlausibleWeb.Endpoint, :user_settings)}
>
account settings
</.styled_link>.
</p>
<p class="mt-3">
To become the owner of this site, you should either reduce your usage, or upgrade your subscription.
</p>
</.notice>
<.notice
x-show="selectedInvitation && selectedInvitation.no_plan"
title="No subscription"
class="mt-4 shadow-sm dark:shadow-none"
>
You are unable to accept the ownership of this site because your account does not have a subscription. To become the owner of this site, you should upgrade to a suitable plan.
</.notice>
</div>
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-700 sm:ml-3 sm:w-auto sm:text-sm"
<.button
x-show="selectedInvitation && !(selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
data-method="post"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/accept')"
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation.invitation_id + '/accept')"
>
Accept &amp; Continue
</button>
</.button>
<.button_link
x-show="selectedInvitation && (selectedInvitation.exceeded_limits || selectedInvitation.no_plan)"
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
class="sm:ml-3 w-full sm:w-auto sm:text-sm"
>
Upgrade
</.button_link>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
data-method="post"
data-csrf={Plug.CSRFProtection.get_csrf_token()}
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/reject')"
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation.invitation_id + '/reject')"
>
Reject
</button>
@ -614,13 +653,54 @@ defmodule PlausibleWeb.Live.Sites do
end)
end
invitations = extract_invitations(sites.entries, assigns.user)
assign(
socket,
sites: sites,
invitations: invitations,
hourly_stats: hourly_stats
)
end
defp extract_invitations(sites, user) do
sites
|> Enum.filter(&(&1.entry_type == "invitation"))
|> Enum.flat_map(& &1.invitations)
|> Enum.map(&check_limits(&1, user))
end
defp check_limits(%{role: :owner, site: site} = invitation, user) do
case Invitations.ensure_can_take_ownership(site, user) do
:ok ->
check_features(invitation, user)
{:error, :no_plan} ->
%{invitation: invitation, no_plan: true}
{:error, {:over_plan_limits, limits}} ->
limits = PlausibleWeb.TextHelpers.pretty_list(limits)
%{invitation: invitation, exceeded_limits: limits}
end
end
defp check_limits(invitation, _), do: %{invitation: invitation}
defp check_features(%{role: :owner, site: site} = invitation, user) do
case Invitations.check_feature_access(site, user, small_build?()) do
:ok ->
%{invitation: invitation}
{:error, {:missing_features, features}} ->
feature_names =
features
|> Enum.map(& &1.display_name())
|> PlausibleWeb.TextHelpers.pretty_list()
%{invitation: invitation, missing_features: feature_names}
end
end
defp set_filter_text(socket, filter_text) do
uri = socket.assigns.uri

View File

@ -31,6 +31,12 @@ defmodule PlausibleWeb.TextHelpers do
"#{rest_string} and #{last_string}"
end
def pretty_list(list) do
list
|> Enum.map(&String.replace("#{&1}", "_", " "))
|> pretty_join()
end
def format_date_range(date_range) do
"#{format_date(date_range.first)} - #{format_date(date_range.last)}"
end

14
priv/paddle_sandbox.pem Normal file
View File

@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwWBTLMN0cdzxAYzzItq1
vWipCcmkaplQPw2BWDfw4+No/GWpyxi08O/9HZEiT4GxEMcmH9QDHfhAzsHjKDCL
1thBlzX0K1qJnuD+hYA9JoEO0DfR5kMIbEJ1rkNhaFjWuI8p70GvRWqcMpwCLUih
7x51Ksin5+zVqf1VP5k5fjzmjOvgUjWwled/Mpy0ts2UF//MGjd+HlbHzW6c86Ck
QdM+t19aMOOKPZgg1TkK+1yykRekWlrDPU2ktAPgf7M9JaugPw1ZBUrCD7KGRGEP
Q+wLrP4UpY0kB39Flf6aNi44L1zjqGgav1UtS44xOhEy55S3pR/6sx9rPXrIKhK7
n1ZZmj/5xUY0L0efVzJXl8NKrSkeyaLJb9G1y2dqh1lQCD7EwlD6ZMT9iZ2WleKK
QuBvEoxSoecISSFvnDHHg3iZYOuABysEOBxSIX37oQpf9AqiTSgJLdrNa1q2ezlf
4GW+eFqkmMt+yN3F2453msNefqEtLK33+eFKyhkPBqgjFiK2CwhpB0QSdaaGj3cG
0ssbm7OGY/rJDp6NLxUQ7x8Ie8BNfZoAaH3WKjDjaA489qRrlh7YIiEjKvl5Y+xv
zmDehftRDvWMtSHRDkmmvzw3i5in+sfHGbDek6m5dX1hO2DfYEMVNSN5qk/ppg2M
ZL09XH4lvIY50LHq1FNwa3ECAwEAAQ==
-----END PUBLIC KEY-----

View File

@ -100,10 +100,8 @@ defmodule Plausible.Billing.QuotaTest do
end
end
describe "ensure_can_subscribe_to_plan/2" do
describe "ensure_within_plan_limits/2" do
test "returns :ok when site and team member limits are reached but not exceeded" do
user = insert(:user)
usage = %{
monthly_pageviews: %{last_30_days: %{total: 1}},
team_members: 3,
@ -112,12 +110,10 @@ defmodule Plausible.Billing.QuotaTest do
plan = Plans.find(@v4_1m_plan_id)
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage) == :ok
assert Quota.ensure_within_plan_limits(usage, plan) == :ok
end
test "returns all exceeded limits" do
user = insert(:user)
usage = %{
monthly_pageviews: %{last_30_days: %{total: 1_150_001}},
team_members: 4,
@ -126,17 +122,27 @@ defmodule Plausible.Billing.QuotaTest do
plan = Plans.find(@v4_1m_plan_id)
{:error, %{exceeded_limits: exceeded_limits}} =
Quota.ensure_can_subscribe_to_plan(user, plan, usage)
{:error, {:over_plan_limits, exceeded_limits}} =
Quota.ensure_within_plan_limits(usage, plan)
assert :monthly_pageview_limit in exceeded_limits
assert :team_member_limit in exceeded_limits
assert :site_limit in exceeded_limits
end
test "by the last 30 days usage, pageview limit for 10k plan is only exceeded when 30% over the limit" do
user = insert(:user)
test "can skip checking the pageview limit" do
usage = %{
monthly_pageviews: %{last_30_days: %{total: 1_150_001}},
team_members: 2,
sites: 8
}
plan = Plans.find(@v4_1m_plan_id)
assert :ok = Quota.ensure_within_plan_limits(usage, plan, ignore_pageview_limit: true)
end
test "by the last 30 days usage, pageview limit for 10k plan is only exceeded when 30% over the limit" do
usage_within_pageview_limit = %{
monthly_pageviews: %{last_30_days: %{total: 13_000}},
team_members: 1,
@ -151,15 +157,13 @@ defmodule Plausible.Billing.QuotaTest do
plan = Plans.find(@v3_plan_id)
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_within_pageview_limit) == :ok
assert Quota.ensure_within_plan_limits(usage_within_pageview_limit, plan) == :ok
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_over_pageview_limit) ==
{:error, %{exceeded_limits: [:monthly_pageview_limit]}}
assert Quota.ensure_within_plan_limits(usage_over_pageview_limit, plan) ==
{:error, {:over_plan_limits, [:monthly_pageview_limit]}}
end
test "by the last 30 days usage, pageview limit for all plans above 10k is exceeded when 15% over the limit" do
user = insert(:user)
usage_within_pageview_limit = %{
monthly_pageviews: %{last_30_days: %{total: 1_150_000}},
team_members: 1,
@ -174,15 +178,13 @@ defmodule Plausible.Billing.QuotaTest do
plan = Plans.find(@v4_1m_plan_id)
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_within_pageview_limit) == :ok
assert Quota.ensure_within_plan_limits(usage_within_pageview_limit, plan) == :ok
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_over_pageview_limit) ==
{:error, %{exceeded_limits: [:monthly_pageview_limit]}}
assert Quota.ensure_within_plan_limits(usage_over_pageview_limit, plan) ==
{:error, {:over_plan_limits, [:monthly_pageview_limit]}}
end
test "by billing cycles usage, pageview limit is exceeded when last two billing cycles exceed by 10%" do
user = insert(:user)
usage_within_pageview_limit = %{
monthly_pageviews: %{penultimate_cycle: %{total: 11_000}, last_cycle: %{total: 10_999}},
team_members: 1,
@ -197,10 +199,30 @@ defmodule Plausible.Billing.QuotaTest do
plan = Plans.find(@v3_plan_id)
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_within_pageview_limit) == :ok
assert Quota.ensure_within_plan_limits(usage_within_pageview_limit, plan) == :ok
assert Quota.ensure_can_subscribe_to_plan(user, plan, usage_over_pageview_limit) ==
{:error, %{exceeded_limits: [:monthly_pageview_limit]}}
assert Quota.ensure_within_plan_limits(usage_over_pageview_limit, plan) ==
{:error, {:over_plan_limits, [:monthly_pageview_limit]}}
end
test "returns error with exceeded limits for enterprise plans" do
user = insert(:user)
usage = %{
monthly_pageviews: %{penultimate_cycle: %{total: 1}, last_cycle: %{total: 1}},
team_members: 1,
sites: 2
}
enterprise_plan =
insert(:enterprise_plan,
user: user,
paddle_plan_id: "whatever",
site_limit: 1
)
assert Quota.ensure_within_plan_limits(usage, enterprise_plan) ==
{:error, {:over_plan_limits, [:site_limit]}}
end
end
@ -575,6 +597,144 @@ defmodule Plausible.Billing.QuotaTest do
end
end
describe "monthly_pageview_usage/2" do
test "returns empty usage for user without subscription and without any sites" do
user =
insert(:user)
|> Plausible.Users.with_subscription()
assert %{
last_30_days: %{
total: 0,
custom_events: 0,
pageviews: 0,
date_range: date_range
}
} = Quota.monthly_pageview_usage(user)
assert date_range.last == Date.utc_today()
assert Date.compare(date_range.first, date_range.last) == :lt
end
test "returns usage for user without subscription with a site" do
user =
insert(:user)
|> Plausible.Users.with_subscription()
site = insert(:site, members: [user])
now = NaiveDateTime.utc_now()
populate_stats(site, [
build(:event, timestamp: Timex.shift(now, days: -40), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -10), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -9), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -8), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -7), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -6), name: "custom")
])
assert %{
last_30_days: %{
total: 5,
custom_events: 2,
pageviews: 3,
date_range: %{}
}
} = Quota.monthly_pageview_usage(user)
end
test "returns usage for user with subscription and a site" do
today = Date.utc_today()
user =
insert(:user,
subscription: build(:subscription, last_bill_date: Timex.shift(today, days: -8))
)
site = insert(:site, members: [user])
now = NaiveDateTime.utc_now()
populate_stats(site, [
build(:event, timestamp: Timex.shift(now, days: -40), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -10), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -9), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -8), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -7), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -6), name: "custom")
])
assert %{
current_cycle: %{
total: 3,
custom_events: 1,
pageviews: 2,
date_range: %{}
},
last_cycle: %{
total: 2,
custom_events: 1,
pageviews: 1,
date_range: %{}
},
penultimate_cycle: %{
total: 1,
custom_events: 1,
pageviews: 0,
date_range: %{}
}
} = Quota.monthly_pageview_usage(user)
end
test "returns usage for only a subset of site IDs" do
today = Date.utc_today()
user =
insert(:user,
subscription: build(:subscription, last_bill_date: Timex.shift(today, days: -8))
)
site1 = insert(:site, members: [user])
site2 = insert(:site, members: [user])
site3 = insert(:site, members: [user])
now = NaiveDateTime.utc_now()
for site <- [site1, site2, site3] do
populate_stats(site, [
build(:event, timestamp: Timex.shift(now, days: -40), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -10), name: "custom"),
build(:event, timestamp: Timex.shift(now, days: -9), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -8), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -7), name: "pageview"),
build(:event, timestamp: Timex.shift(now, days: -6), name: "custom")
])
end
assert %{
current_cycle: %{
total: 6,
custom_events: 2,
pageviews: 4,
date_range: %{}
},
last_cycle: %{
total: 4,
custom_events: 2,
pageviews: 2,
date_range: %{}
},
penultimate_cycle: %{
total: 2,
custom_events: 2,
pageviews: 0,
date_range: %{}
}
} = Quota.monthly_pageview_usage(user, [site1.id, site3.id])
end
end
describe "usage_cycle/1" do
setup do
user = insert(:user)

View File

@ -5,30 +5,43 @@ defmodule Plausible.Site.AdminTest do
setup do
admin_user = insert(:user)
conn = %Plug.Conn{assigns: %{current_user: admin_user}}
action = Plausible.SiteAdmin.list_actions(conn)[:transfer_ownership][:action]
transfer_action = Plausible.SiteAdmin.list_actions(conn)[:transfer_ownership][:action]
transfer_direct_action =
Plausible.SiteAdmin.list_actions(conn)[:transfer_ownership_direct][:action]
{:ok,
%{
action: action,
transfer_action: transfer_action,
transfer_direct_action: transfer_direct_action,
conn: conn
}}
end
describe "bulk transferring site ownership" do
test "user has to select at least one site", %{conn: conn, action: action} do
test "user has to select at least one site", %{conn: conn, transfer_action: action} do
assert action.(conn, [], %{}) == {:error, "Please select at least one site from the list"}
end
test "new owner must be an existing user", %{conn: conn, action: action} do
test "new owner must be an existing user", %{conn: conn, transfer_action: action} do
site = insert(:site)
assert action.(conn, [site], %{"email" => "random@email.com"}) ==
{:error, "User could not be found"}
end
test "new owner can't be the same as old owner", %{conn: conn, transfer_action: action} do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
assert {:error, "User is already an owner of one of the sites"} =
action.(conn, [site], %{"email" => current_owner.email})
end
test "initiates ownership transfer for multiple sites in one action", %{
conn: conn,
action: action
transfer_action: action
} do
current_owner = insert(:user)
new_owner = insert(:user)
@ -52,4 +65,72 @@ defmodule Plausible.Site.AdminTest do
)
end
end
describe "bulk transferring site ownership directly" do
test "user has to select at least one site", %{conn: conn, transfer_direct_action: action} do
assert action.(conn, [], %{}) == {:error, "Please select at least one site from the list"}
end
test "new owner must be an existing user", %{conn: conn, transfer_direct_action: action} do
site = insert(:site)
assert action.(conn, [site], %{"email" => "random@email.com"}) ==
{:error, "User could not be found"}
end
test "new owner can't be the same as old owner", %{conn: conn, transfer_direct_action: action} do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
assert {:error, "User is already an owner of one of the sites"} =
action.(conn, [site], %{"email" => current_owner.email})
end
@tag :full_build_only
test "new owner's plan must accommodate the transferred site", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = insert(:user)
new_owner =
insert(:user,
subscription:
build(:growth_subscription,
last_bill_date: Timex.shift(today, days: -5)
)
)
# fills the site limit quota
insert_list(10, :site, members: [new_owner])
site = insert(:site, members: [current_owner])
assert {:error, "Plan limits exceeded" <> _} =
action.(conn, [site], %{"email" => new_owner.email})
end
test "executes ownership transfer for multiple sites in one action", %{
conn: conn,
transfer_direct_action: action
} do
today = Date.utc_today()
current_owner = insert(:user)
new_owner =
insert(:user,
subscription: build(:subscription, last_bill_date: Timex.shift(today, days: -5))
)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
site2 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
assert :ok = action.(conn, [site1, site2], %{"email" => new_owner.email})
end
end
end

View File

@ -1,41 +1,42 @@
defmodule Plausible.Site.Memberships.AcceptInvitationTest do
use Plausible
require Plausible.Billing.Subscription.Status
use Plausible.DataCase, async: true
use Bamboo.Test
alias Plausible.Site.Memberships.AcceptInvitation
describe "transfer_ownership/3" do
for {label, opts} <- [{"cloud", []}, {"selfhosted", [selfhost?: true]}] do
test "transfers ownership on #{label} instance" do
site = insert(:site, memberships: [])
existing_owner = insert(:user)
test "transfers ownership succeeds" do
site = insert(:site, memberships: [])
existing_owner = insert(:user)
existing_membership =
insert(:site_membership, user: existing_owner, site: site, role: :owner)
existing_membership =
insert(:site_membership, user: existing_owner, site: site, role: :owner)
new_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
assert {:ok, new_membership} =
AcceptInvitation.transfer_ownership(site, new_owner, unquote(opts))
assert {:ok, new_membership} =
AcceptInvitation.transfer_ownership(site, new_owner)
assert new_membership.site_id == site.id
assert new_membership.user_id == new_owner.id
assert new_membership.role == :owner
assert new_membership.site_id == site.id
assert new_membership.user_id == new_owner.id
assert new_membership.role == :owner
existing_membership = Repo.reload!(existing_membership)
assert existing_membership.user_id == existing_owner.id
assert existing_membership.site_id == site.id
assert existing_membership.role == :admin
existing_membership = Repo.reload!(existing_membership)
assert existing_membership.user_id == existing_owner.id
assert existing_membership.site_id == site.id
assert existing_membership.role == :admin
assert_no_emails_delivered()
end
assert_no_emails_delivered()
end
for role <- [:viewer, :admin] do
test "upgrades existing #{role} membership into an owner" do
existing_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
site =
insert(:site,
@ -55,128 +56,113 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
end
end
test "does not degrade or alter trial when accepting ownership transfer by self" do
test "trial transferring to themselves gets a transfer_to_self error" do
owner = insert(:user, trial_expiry_date: nil)
site = insert(:site, memberships: [build(:site_membership, user: owner, role: :owner)])
assert {:ok, %{id: membership_id}} = AcceptInvitation.transfer_ownership(site, owner)
assert %{id: ^membership_id, role: :owner} =
Plausible.Repo.get_by(Plausible.Site.Membership, user_id: owner.id)
assert {:error, :transfer_to_self} = AcceptInvitation.transfer_ownership(site, owner)
assert %{role: :owner} = Plausible.Repo.get_by(Plausible.Site.Membership, user_id: owner.id)
assert Repo.reload!(owner).trial_expiry_date == nil
end
on_full_build do
test "locks the site if the new owner has no active subscription or trial" do
existing_owner = insert(:user)
@tag :full_build_only
test "does not allow transferring to an account without an active subscription" do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
trial_user = insert(:user)
invited_user = insert(:user, trial_expiry_date: nil)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
user_on_free_10k =
insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert {:ok, _membership} = AcceptInvitation.transfer_ownership(site, new_owner)
assert Repo.reload!(site).locked
end
end
test "does not lock the site or set trial expiry date if the instance is selfhosted" do
existing_owner = insert(:user)
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
user_on_expired_subscription =
insert(:user,
subscription:
build(:growth_subscription,
status: Plausible.Billing.Subscription.Status.deleted(),
next_bill_date: Timex.shift(Timex.today(), days: -1)
)
)
new_owner = insert(:user, trial_expiry_date: nil)
assert {:ok, _membership} =
AcceptInvitation.transfer_ownership(site, new_owner, selfhost?: true)
assert Repo.reload!(new_owner).trial_expiry_date == nil
refute Repo.reload!(site).locked
end
on_full_build do
test "ends trial of the new owner immediately" do
existing_owner = insert(:user)
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), 7))
assert {:ok, _membership} = AcceptInvitation.transfer_ownership(site, new_owner)
assert Repo.reload!(new_owner).trial_expiry_date == Date.add(Date.utc_today(), -1)
assert Repo.reload!(site).locked
end
end
on_full_build do
test "sets user's trial expiry date to yesterday if they don't have one" do
existing_owner = insert(:user)
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
new_owner = insert(:user, trial_expiry_date: nil)
assert {:ok, _membership} = AcceptInvitation.transfer_ownership(site, new_owner)
assert Repo.reload!(new_owner).trial_expiry_date == Date.add(Date.utc_today(), -1)
assert Repo.reload!(site).locked
end
end
on_full_build do
test "ends grace period and sends an email about it if new owner is past grace period" do
existing_owner = insert(:user)
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription, user: new_owner, next_bill_date: Timex.today())
new_owner =
new_owner
|> Plausible.Auth.GracePeriod.start_changeset(100)
|> then(fn changeset ->
grace_period =
changeset
|> get_field(:grace_period)
|> Map.put(:end_date, Date.add(Date.utc_today(), -1))
change(changeset, grace_period: grace_period)
end)
|> Repo.update!()
assert {:ok, _membership} = AcceptInvitation.transfer_ownership(site, new_owner)
assert Repo.reload!(new_owner).grace_period.is_over
assert Repo.reload!(site).locked
assert_email_delivered_with(
to: [{"Jane Smith", new_owner.email}],
subject: "[Action required] Your Plausible dashboard is now locked"
user_on_paused_subscription =
insert(:user,
subscription:
build(:growth_subscription, status: Plausible.Billing.Subscription.Status.paused())
)
end
assert {:error, :no_plan} = AcceptInvitation.transfer_ownership(site, trial_user)
assert {:error, :no_plan} = AcceptInvitation.transfer_ownership(site, invited_user)
assert {:error, :no_plan} = AcceptInvitation.transfer_ownership(site, user_on_free_10k)
assert {:error, :no_plan} =
AcceptInvitation.transfer_ownership(site, user_on_expired_subscription)
assert {:error, :no_plan} =
AcceptInvitation.transfer_ownership(site, user_on_paused_subscription)
end
test "does not allow transferring to self" do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
assert {:error, :transfer_to_self} =
AcceptInvitation.transfer_ownership(site, current_owner)
end
@tag :full_build_only
test "does not allow transferring to and account without suitable plan" do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
new_owner =
insert(:user, subscription: build(:growth_subscription))
# fill site quota
insert_list(10, :site, members: [new_owner])
assert {:error, {:over_plan_limits, [:site_limit]}} =
AcceptInvitation.transfer_ownership(site, new_owner)
end
@tag :small_build_only
test "allows transferring to an account without a subscription on self hosted" do
current_owner = insert(:user)
site = insert(:site, members: [current_owner])
trial_user = insert(:user)
invited_user = insert(:user, trial_expiry_date: nil)
user_on_free_10k =
insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
user_on_expired_subscription =
insert(:user,
subscription:
build(:growth_subscription,
status: Plausible.Billing.Subscription.Status.deleted(),
next_bill_date: Timex.shift(Timex.today(), days: -1)
)
)
user_on_paused_subscription =
insert(:user,
subscription:
build(:growth_subscription, status: Plausible.Billing.Subscription.Status.paused())
)
assert {:ok, _} = AcceptInvitation.transfer_ownership(site, trial_user)
assert {:ok, _} = AcceptInvitation.transfer_ownership(site, invited_user)
assert {:ok, _} =
AcceptInvitation.transfer_ownership(site, user_on_free_10k)
assert {:ok, _} =
AcceptInvitation.transfer_ownership(site, user_on_expired_subscription)
assert {:ok, _} =
AcceptInvitation.transfer_ownership(site, user_on_paused_subscription)
end
end
@ -268,53 +254,52 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
end
describe "accept_invitation/3 - ownership transfers" do
for {label, opts} <- [{"cloud", []}, {"selfhosted", [selfhost?: true]}] do
test "converts an ownership transfer into a membership on #{label} instance" do
site = insert(:site, memberships: [])
existing_owner = insert(:user)
test "converts an ownership transfer into a membership" do
site = insert(:site, memberships: [])
existing_owner = insert(:user)
existing_membership =
insert(:site_membership, user: existing_owner, site: site, role: :owner)
existing_membership =
insert(:site_membership, user: existing_owner, site: site, role: :owner)
new_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, new_membership} =
AcceptInvitation.accept_invitation(
invitation.invitation_id,
new_owner,
unquote(opts)
)
assert new_membership.site_id == site.id
assert new_membership.user_id == new_owner.id
assert new_membership.role == :owner
refute Repo.reload(invitation)
existing_membership = Repo.reload!(existing_membership)
assert existing_membership.user_id == existing_owner.id
assert existing_membership.site_id == site.id
assert existing_membership.role == :admin
assert_email_delivered_with(
to: [nil: existing_owner.email],
subject:
"[Plausible Analytics] #{new_owner.email} accepted the ownership transfer of #{site.domain}"
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
email: new_owner.email,
role: :owner
)
end
assert {:ok, new_membership} =
AcceptInvitation.accept_invitation(
invitation.invitation_id,
new_owner
)
assert new_membership.site_id == site.id
assert new_membership.user_id == new_owner.id
assert new_membership.role == :owner
refute Repo.reload(invitation)
existing_membership = Repo.reload!(existing_membership)
assert existing_membership.user_id == existing_owner.id
assert existing_membership.site_id == site.id
assert existing_membership.role == :admin
assert_email_delivered_with(
to: [nil: existing_owner.email],
subject:
"[Plausible Analytics] #{new_owner.email} accepted the ownership transfer of #{site.domain}"
)
end
for role <- [:viewer, :admin] do
test "upgrades existing #{role} membership into an owner" do
existing_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
site =
insert(:site,
@ -347,6 +332,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
test "does note degrade or alter trial when accepting ownership transfer by self" do
owner = insert(:user, trial_expiry_date: nil)
insert(:growth_subscription, user: owner)
site = insert(:site, memberships: [build(:site_membership, user: owner, role: :owner)])
invitation =
@ -368,157 +354,175 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do
end
@tag :full_build_only
test "locks the site if the new owner has no active subscription or trial" do
existing_owner = insert(:user)
test "does not allow transferring ownership to a non-member user when at team members limit" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(3, :site_membership, role: :admin)
)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, _membership} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
assert Repo.reload!(site).locked
end
test "does not lock the site or set trial expiry date if the instance is selfhosted" do
existing_owner = insert(:user)
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
new_owner = insert(:user, trial_expiry_date: nil)
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, _membership} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner,
selfhost?: true
assert {:error, {:over_plan_limits, [:team_member_limit]}} =
AcceptInvitation.accept_invitation(
invitation.invitation_id,
new_owner
)
assert Repo.reload!(new_owner).trial_expiry_date == nil
refute Repo.reload!(site).locked
end
@tag :full_build_only
test "ends trial of the new owner immediately" do
existing_owner = insert(:user)
test "allows transferring ownership to existing site member when at team members limit" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
memberships:
[
build(:site_membership, user: old_owner, role: :owner),
build(:site_membership, user: new_owner, role: :admin)
] ++
build_list(2, :site_membership, role: :admin)
)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), 7))
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, _membership} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
assert Repo.reload!(new_owner).trial_expiry_date == Date.add(Date.utc_today(), -1)
assert Repo.reload!(site).locked
assert {:ok, _} =
AcceptInvitation.accept_invitation(
invitation.invitation_id,
new_owner
)
end
@tag :full_build_only
test "sets user's trial expiry date to yesterday if they don't have one" do
existing_owner = insert(:user)
test "does not allow transferring ownership when sites limit exceeded" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
)
insert_list(10, :site, members: [new_owner])
new_owner = insert(:user, trial_expiry_date: nil)
site = insert(:site, members: [old_owner])
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, _membership} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
assert Repo.reload!(new_owner).trial_expiry_date == Date.add(Date.utc_today(), -1)
assert Repo.reload!(site).locked
assert {:error, {:over_plan_limits, [:site_limit]}} =
AcceptInvitation.accept_invitation(
invitation.invitation_id,
new_owner
)
end
@tag :full_build_only
test "ends grace period and sends an email about it if new owner is past grace period" do
existing_owner = insert(:user)
test "does not allow transferring ownership when pageview limit exceeded" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
locked: false,
memberships: [build(:site_membership, user: existing_owner, role: :owner)]
new_owner_site = insert(:site, members: [new_owner])
old_owner_site = insert(:site, members: [old_owner])
somewhere_last_month = NaiveDateTime.utc_now() |> Timex.shift(days: -5)
somewhere_penultimate_month = NaiveDateTime.utc_now() |> Timex.shift(days: -35)
generate_usage_for(new_owner_site, 5_000, somewhere_last_month)
generate_usage_for(new_owner_site, 1_000, somewhere_penultimate_month)
generate_usage_for(old_owner_site, 6_000, somewhere_last_month)
generate_usage_for(old_owner_site, 10_000, somewhere_penultimate_month)
invitation =
insert(:invitation,
site_id: old_owner_site.id,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
new_owner = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription, user: new_owner, next_bill_date: Timex.today())
assert {:error, {:over_plan_limits, [:monthly_pageview_limit]}} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
end
@tag :full_build_only
test "allow_next_upgrade_override field has no effect when checking the pageview limit on ownership transfer" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner =
new_owner
|> Plausible.Auth.GracePeriod.start_changeset(100)
|> then(fn changeset ->
grace_period =
changeset
|> get_field(:grace_period)
|> Map.put(:end_date, Date.add(Date.utc_today(), -1))
insert(:user,
subscription: build(:growth_subscription),
allow_next_upgrade_override: true
)
change(changeset, grace_period: grace_period)
end)
|> Repo.update!()
new_owner_site = insert(:site, members: [new_owner])
old_owner_site = insert(:site, members: [old_owner])
somewhere_last_month = NaiveDateTime.utc_now() |> Timex.shift(days: -5)
somewhere_penultimate_month = NaiveDateTime.utc_now() |> Timex.shift(days: -35)
generate_usage_for(new_owner_site, 5_000, somewhere_last_month)
generate_usage_for(new_owner_site, 1_000, somewhere_penultimate_month)
generate_usage_for(old_owner_site, 6_000, somewhere_last_month)
generate_usage_for(old_owner_site, 10_000, somewhere_penultimate_month)
invitation =
insert(:invitation,
site_id: site.id,
inviter: existing_owner,
site_id: old_owner_site.id,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
assert {:ok, _membership} =
assert {:error, {:over_plan_limits, [:monthly_pageview_limit]}} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
end
assert Repo.reload!(new_owner).grace_period.is_over
assert Repo.reload!(site).locked
@tag :full_build_only
test "does not allow transferring ownership when many limits exceeded at once" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
assert_email_delivered_with(
to: [{"Jane Smith", new_owner.email}],
subject: "[Action required] Your Plausible dashboard is now locked"
)
insert_list(10, :site, members: [new_owner])
site =
insert(:site,
props_enabled: true,
allowed_event_props: ["author"],
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(3, :site_membership, role: :admin)
)
invitation =
insert(:invitation,
site_id: site.id,
inviter: old_owner,
email: new_owner.email,
role: :owner
)
assert {:error, {:over_plan_limits, [:team_member_limit, :site_limit]}} =
AcceptInvitation.accept_invitation(invitation.invitation_id, new_owner)
end
end
end

View File

@ -82,7 +82,7 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
end
test "sends ownership transfer email when inviter role is owner" do
test "sends ownership transfer email when invitation role is owner" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
@ -140,11 +140,11 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :owner)
end
test "does not check for limits when transferring ownership" do
test "allows creating an ownership transfer even when at team member limit" do
inviter = insert(:user)
memberships =
[build(:site_membership, user: inviter, role: :owner)] ++ build_list(5, :site_membership)
[build(:site_membership, user: inviter, role: :owner)] ++ build_list(3, :site_membership)
site = insert(:site, memberships: memberships)
@ -186,94 +186,6 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :admin)
end
@tag :full_build_only
test "does not allow transferring ownership when site does not fit the new owner subscription" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site_with_too_many_team_members =
insert(:site,
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(6, :site_membership, role: :admin)
)
site_using_premium_features =
insert(:site,
memberships: [build(:site_membership, user: old_owner, role: :owner)],
props_enabled: true,
allowed_event_props: ["author"]
)
assert {:error, :upgrade_required} =
CreateInvitation.create_invitation(
site_with_too_many_team_members,
old_owner,
new_owner.email,
:owner
)
assert {:error, :upgrade_required} =
CreateInvitation.create_invitation(
site_using_premium_features,
old_owner,
new_owner.email,
:owner
)
end
test "allows transferring ownership to growth plan when premium feature enabled but not used" do
old_owner = insert(:user)
site = insert(:site, members: [old_owner], props_enabled: true)
new_owner = insert(:user, subscription: build(:growth_subscription))
assert {:ok, _invitation} =
CreateInvitation.create_invitation(
site,
old_owner,
new_owner.email,
:owner
)
end
test "allows transferring ownership when invitee reaches (but does not exceed) site limit" do
old_owner = insert(:user)
site = insert(:site, members: [old_owner])
new_owner = insert(:user, subscription: build(:growth_subscription))
for _ <- 1..9, do: insert(:site, members: [new_owner])
assert {:ok, _invitation} =
CreateInvitation.create_invitation(
site,
old_owner,
new_owner.email,
:owner
)
end
test "allows transferring ownership when invitee reaches (but does not exceed) team member limit" do
old_owner = insert(:user)
site =
insert(:site,
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(2, :site_membership, role: :admin)
)
new_owner = insert(:user, subscription: build(:growth_subscription))
assert {:ok, _invitation} =
CreateInvitation.create_invitation(
site,
old_owner,
new_owner.email,
:owner
)
end
end
describe "bulk_create_invitation/5" do
@ -360,6 +272,7 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
test "transfers ownership for multiple sites in one action" do
current_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
@ -397,6 +310,7 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
test "returns error when user is already an owner for one of the sites" do
current_owner = insert(:user)
new_owner = insert(:user)
insert(:growth_subscription, user: new_owner)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
@ -418,6 +332,74 @@ defmodule Plausible.Site.Memberships.CreateInvitationTest do
role: :owner
)
end
@tag :full_build_only
test "does not allow transferring ownership to a non-member user when at team members limit" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(3, :site_membership, role: :admin)
)
assert {:error, {:over_plan_limits, [:team_member_limit]}} =
CreateInvitation.bulk_transfer_ownership_direct([site], new_owner)
end
@tag :full_build_only
test "allows transferring ownership to existing site member when at team members limit" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site =
insert(:site,
memberships:
[
build(:site_membership, user: old_owner, role: :owner),
build(:site_membership, user: new_owner, role: :admin)
] ++
build_list(2, :site_membership, role: :admin)
)
assert {:ok, _} =
CreateInvitation.bulk_transfer_ownership_direct([site], new_owner)
end
@tag :full_build_only
test "does not allow transferring ownership when sites limit exceeded" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
insert_list(10, :site, members: [new_owner])
site = insert(:site, members: [old_owner])
assert {:error, {:over_plan_limits, [:site_limit]}} =
CreateInvitation.bulk_transfer_ownership_direct([site], new_owner)
end
@tag :full_build_only
test "exceeding limits error takes precedence over missing features" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
insert_list(10, :site, members: [new_owner])
site =
insert(:site,
props_enabled: true,
allowed_event_props: ["author"],
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(3, :site_membership, role: :admin)
)
assert {:error, {:over_plan_limits, [:team_member_limit, :site_limit]}} =
CreateInvitation.bulk_transfer_ownership_direct([site], new_owner)
end
end
defp assert_invitation_exists(site, email, role) do

View File

@ -37,7 +37,7 @@ defmodule PlausibleWeb.BillingControllerTest do
conn = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan))
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Unable to subscribe to this plan because the following limits are exceeded: [:team_member_limit]"
"Unable to subscribe to this plan because the following limits are exceeded: team member limit"
end
test "errors if usage exceeds site limit even when user.next_upgrade_override is true", %{
@ -54,7 +54,7 @@ defmodule PlausibleWeb.BillingControllerTest do
subscription = Plausible.Repo.get_by(Plausible.Billing.Subscription, user_id: user.id)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "are exceeded: [:site_limit]"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "are exceeded: site limit"
assert subscription.paddle_plan_id == "123123"
end
@ -74,7 +74,7 @@ defmodule PlausibleWeb.BillingControllerTest do
subscription = Plausible.Repo.get_by(Plausible.Billing.Subscription, user_id: user.id)
assert Phoenix.Flash.get(conn1.assigns.flash, :error) =~
"are exceeded: [:monthly_pageview_limit]"
"are exceeded: monthly pageview limit"
assert subscription.paddle_plan_id == "123123"

View File

@ -60,10 +60,17 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
old_owner = insert(:user)
site = insert(:site, members: [old_owner])
insert(:growth_subscription, user: user)
invitation =
insert(:invitation, site_id: site.id, inviter: old_owner, email: user.email, role: :owner)
post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
conn = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
assert redirected_to(conn, 302) == "/#{URI.encode_www_form(site.domain)}"
assert Phoenix.Flash.get(conn.assigns.flash, :success) =~
"You now have access to"
refute Repo.exists?(from(i in Plausible.Auth.Invitation, where: i.email == ^user.email))
@ -77,6 +84,43 @@ defmodule PlausibleWeb.Site.InvitationControllerTest do
assert new_owner_membership.role == :owner
end
@tag :full_build_only
test "fails when new owner has no plan", %{conn: conn, user: user} do
old_owner = insert(:user)
site = insert(:site, members: [old_owner])
invitation =
insert(:invitation, site_id: site.id, inviter: old_owner, email: user.email, role: :owner)
conn = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"No existing subscription"
end
@tag :full_build_only
test "fails when new owner's plan is unsuitable", %{conn: conn, user: user} do
old_owner = insert(:user)
site = insert(:site, members: [old_owner])
insert(:growth_subscription, user: user)
# fill site limit quota
insert_list(10, :site, members: [user])
invitation =
insert(:invitation, site_id: site.id, inviter: old_owner, email: user.email, role: :owner)
conn = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept")
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Plan limits exceeded: site limit."
end
end
describe "POST /sites/invitations/:invitation_id/reject" do

View File

@ -733,6 +733,23 @@ defmodule PlausibleWeb.Live.ChoosePlanTest do
end
end
describe "for a user with no sites but pending ownership transfer" do
setup [:create_user, :log_in]
test "allows to subscribe and does not render a notice", %{conn: conn, user: user} do
old_owner = insert(:user)
site = insert(:site, members: [old_owner])
insert(:invitation, site_id: site.id, inviter: old_owner, email: user.email, role: :owner)
{:ok, _lv, doc} = get_liveview(conn)
refute text_of_element(doc, "#upgrade-eligible-notice") =~ "You cannot start a subscription"
refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none"
refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none"
assert text_of_element(doc, @growth_plan_box) =~ "Recommended"
end
end
defp subscribe_v4_growth(%{user: user}) do
create_subscription_for(user, paddle_plan_id: @v4_growth_200k_yearly_plan_id)
end

View File

@ -15,6 +15,81 @@ defmodule PlausibleWeb.Live.SitesTest do
assert text(html) =~ "You don't have any sites yet"
end
@tag :full_build_only
test "renders ownership transfer invitation for a case with no plan", %{
conn: conn,
user: user
} do
site = insert(:site)
invitation =
insert(:invitation,
site_id: site.id,
inviter: build(:user),
email: user.email,
role: :owner
)
{:ok, _lv, html} = live(conn, "/sites")
invitation_data = get_invitation_data(html)
assert get_in(invitation_data, ["invitations", invitation.invitation_id, "no_plan"])
end
@tag :full_build_only
test "renders ownership transfer invitation for a case with exceeded limits", %{
conn: conn,
user: user
} do
site = insert(:site)
insert(:growth_subscription, user: user)
# fill site quota
insert_list(10, :site, members: [user])
invitation =
insert(:invitation,
site_id: site.id,
inviter: build(:user),
email: user.email,
role: :owner
)
{:ok, _lv, html} = live(conn, "/sites")
invitation_data = get_invitation_data(html)
assert get_in(invitation_data, ["invitations", invitation.invitation_id, "exceeded_limits"]) ==
"site limit"
end
@tag :full_build_only
test "renders ownership transfer invitation for a case with missing features", %{
conn: conn,
user: user
} do
site = insert(:site, allowed_event_props: ["dummy"])
insert(:growth_subscription, user: user)
invitation =
insert(:invitation,
site_id: site.id,
inviter: build(:user),
email: user.email,
role: :owner
)
{:ok, _lv, html} = live(conn, "/sites")
invitation_data = get_invitation_data(html)
assert get_in(invitation_data, ["invitations", invitation.invitation_id, "missing_features"]) ==
"Custom Properties"
end
test "renders 24h visitors correctly", %{conn: conn, user: user} do
site = insert(:site, members: [user])
@ -140,4 +215,14 @@ defmodule PlausibleWeb.Live.SitesTest do
|> element("form")
|> render_change(%{id => text})
end
defp get_invitation_data(html) do
html
|> text_of_attr("div[x-data]", "x-data")
|> String.trim("dropdown")
|> String.replace("selectedInvitation:", "\"selectedInvitation\":")
|> String.replace("invitationOpen:", "\"invitationOpen\":")
|> String.replace("invitations:", "\"invitations\":")
|> Jason.decode!()
end
end