mirror of
https://github.com/plausible/analytics.git
synced 2024-11-22 02:27:57 +03:00
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:
parent
1678fa10f4
commit
9d97dc1912
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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 ->
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 & 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
|
||||
|
||||
|
@ -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
14
priv/paddle_sandbox.pem
Normal 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-----
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user