From 9d97dc1912c2ab976f2f0c8ed826ba0e4982f2e7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 20 Dec 2023 15:56:49 +0100 Subject: [PATCH] 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 --- lib/plausible/billing/billing.ex | 24 +- lib/plausible/billing/feature.ex | 3 + lib/plausible/billing/paddle_api.ex | 14 +- lib/plausible/billing/plans.ex | 10 +- lib/plausible/billing/quota.ex | 118 ++-- lib/plausible/site/admin.ex | 23 +- lib/plausible/site/membership.ex | 7 +- lib/plausible/site/memberships.ex | 9 + .../site/memberships/accept_invitation.ex | 148 +++-- .../site/memberships/create_invitation.ex | 61 +-- lib/plausible/site/memberships/invitations.ex | 84 +++ .../components/billing/billing.ex | 2 +- .../components/billing/plan_box.ex | 11 +- .../controllers/api/paddle_controller.ex | 18 +- .../controllers/billing_controller.ex | 4 +- .../controllers/invitation_controller.ex | 13 + .../controllers/site/membership_controller.ex | 10 - lib/plausible_web/live/choose_plan.ex | 21 +- lib/plausible_web/live/sites.ex | 140 ++++- lib/plausible_web/views/text_helpers.ex | 6 + priv/paddle_sandbox.pem | 14 + test/plausible/billing/quota_test.exs | 206 ++++++- test/plausible/site/admin_test.exs | 91 +++- .../memberships/accept_invitation_test.exs | 514 +++++++++--------- .../memberships/create_invitation_test.exs | 164 +++--- .../controllers/billing_controller_test.exs | 6 +- .../invitation_controller_test.exs | 46 +- test/plausible_web/live/choose_plan_test.exs | 17 + test/plausible_web/live/sites_test.exs | 85 +++ 29 files changed, 1254 insertions(+), 615 deletions(-) create mode 100644 priv/paddle_sandbox.pem diff --git a/lib/plausible/billing/billing.ex b/lib/plausible/billing/billing.ex index 5f5b9e9ef1..56983a93ca 100644 --- a/lib/plausible/billing/billing.ex +++ b/lib/plausible/billing/billing.ex @@ -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) diff --git a/lib/plausible/billing/feature.ex b/lib/plausible/billing/feature.ex index 16008950bc..d3dcd7f2f2 100644 --- a/lib/plausible/billing/feature.ex +++ b/lib/plausible/billing/feature.ex @@ -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. """ diff --git a/lib/plausible/billing/paddle_api.ex b/lib/plausible/billing/paddle_api.ex index b50699a675..4fb4558c76 100644 --- a/lib/plausible/billing/paddle_api.ex +++ b/lib/plausible/billing/paddle_api.ex @@ -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 diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 4e0f467632..fadd095279 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -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 diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex index 13a6b89ad0..6fa5cba816 100644 --- a/lib/plausible/billing/quota.ex +++ b/lib/plausible/billing/quota.ex @@ -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 diff --git a/lib/plausible/site/admin.ex b/lib/plausible/site/admin.ex index f6cf075904..c4b75113f2 100644 --- a/lib/plausible/site/admin.ex +++ b/lib/plausible/site/admin.ex @@ -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"} diff --git a/lib/plausible/site/membership.ex b/lib/plausible/site/membership.ex index 6e663faf59..8192a0daa0 100644 --- a/lib/plausible/site/membership.ex +++ b/lib/plausible/site/membership.ex @@ -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 diff --git a/lib/plausible/site/memberships.ex b/lib/plausible/site/memberships.ex index e940f9af61..f277e277af 100644 --- a/lib/plausible/site/memberships.ex +++ b/lib/plausible/site/memberships.ex @@ -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 = diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index a96b16c976..e467bc3544 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -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() diff --git a/lib/plausible/site/memberships/create_invitation.ex b/lib/plausible/site/memberships/create_invitation.ex index 9ebce209d0..23f9e4b983 100644 --- a/lib/plausible/site/memberships/create_invitation.ex +++ b/lib/plausible/site/memberships/create_invitation.ex @@ -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 diff --git a/lib/plausible/site/memberships/invitations.ex b/lib/plausible/site/memberships/invitations.ex index f51e5beb9e..4fd0df0204 100644 --- a/lib/plausible/site/memberships/invitations.ex +++ b/lib/plausible/site/memberships/invitations.ex @@ -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 diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index 9cc57470c7..d3126bd2dd 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -259,7 +259,7 @@ defmodule PlausibleWeb.Components.Billing do ~H""" -