diff --git a/lib/plausible/auth/invitation.ex b/lib/plausible/auth/invitation.ex index 5a24b11ba..d0a2eb70f 100644 --- a/lib/plausible/auth/invitation.ex +++ b/lib/plausible/auth/invitation.ex @@ -2,6 +2,8 @@ defmodule Plausible.Auth.Invitation do use Ecto.Schema import Ecto.Changeset + @type t() :: %__MODULE__{} + @derive {Jason.Encoder, only: [:invitation_id, :role, :site]} @required [:email, :role, :site_id, :inviter_id] schema "invitations" do diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 6effc2b4b..704a75ca1 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -2,7 +2,7 @@ defmodule Plausible.Billing.Plan do @moduledoc false @derive Jason.Encoder - @enforce_keys ~w(kind site_limit monthly_pageview_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a + @enforce_keys ~w(kind site_limit monthly_pageview_limit team_member_limit volume monthly_cost yearly_cost monthly_product_id yearly_product_id)a defstruct @enforce_keys @type t() :: @@ -10,6 +10,7 @@ defmodule Plausible.Billing.Plan do kind: atom(), monthly_pageview_limit: non_neg_integer(), site_limit: non_neg_integer(), + team_member_limit: non_neg_integer() | :unlimited, volume: String.t(), monthly_cost: String.t() | nil, yearly_cost: String.t() | nil, @@ -36,15 +37,22 @@ defmodule Plausible.Billing.Plans do path |> File.read!() |> Jason.decode!(keys: :atoms!) - |> Enum.map( - &Map.put( - &1, - :volume, - PlausibleWeb.StatsView.large_number_format(&1.monthly_pageview_limit) - ) - ) - |> Enum.map(&Map.put(&1, :kind, String.to_atom(&1.kind))) - |> Enum.map(&struct!(Plausible.Billing.Plan, &1)) + |> Enum.map(fn raw -> + team_member_limit = + case raw.team_member_limit do + number when is_integer(number) -> number + "unlimited" -> :unlimited + _any -> raise ArgumentError, "Failed to parse team member limit from plan JSON files" + end + + volume = PlausibleWeb.StatsView.large_number_format(raw.monthly_pageview_limit) + + raw + |> Map.put(:volume, volume) + |> Map.put(:kind, String.to_atom(raw.kind)) + |> Map.put(:team_member_limit, team_member_limit) + |> then(&struct!(Plausible.Billing.Plan, &1)) + end) Module.put_attribute(__MODULE__, f, contents) Module.put_attribute(__MODULE__, :external_resource, path) diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex index bb5250bfc..94b5f080f 100644 --- a/lib/plausible/billing/quota.ex +++ b/lib/plausible/billing/quota.ex @@ -3,6 +3,7 @@ defmodule Plausible.Billing.Quota do This module provides functions to work with plans usage and limits. """ + import Ecto.Query alias Plausible.Billing.Plans @limit_sites_since ~D[2021-05-05] @@ -79,6 +80,55 @@ defmodule Plausible.Billing.Quota do |> Tuple.sum() end + @team_member_limit_for_trials 5 + @spec team_member_limit(Plausible.Auth.User.t()) :: non_neg_integer() + @doc """ + Returns the limit of team members a user can have in their sites. + """ + def team_member_limit(user) do + user = Plausible.Users.with_subscription(user) + + case Plans.get_subscription_plan(user.subscription) do + %Plausible.Billing.EnterprisePlan{} -> :unlimited + %Plausible.Billing.Plan{team_member_limit: limit} -> limit + :free_10k -> :unlimited + nil -> @team_member_limit_for_trials + end + end + + @spec team_member_usage(Plausible.Auth.User.t()) :: integer() + @doc """ + Returns the total count of team members and pending invitations associated + with the user's sites. + """ + def team_member_usage(user) do + owned_sites_query = + from sm in Plausible.Site.Membership, + where: sm.role == :owner and sm.user_id == ^user.id, + select: %{site_id: sm.site_id} + + team_members_query = + from os in subquery(owned_sites_query), + inner_join: sm in Plausible.Site.Membership, + on: sm.site_id == os.site_id, + inner_join: u in assoc(sm, :user), + select: %{email: u.email} + + invitations_and_team_members_query = + from i in Plausible.Auth.Invitation, + inner_join: os in subquery(owned_sites_query), + on: i.site_id == os.site_id, + select: %{email: i.email}, + union: ^team_members_query + + query = + from itm in subquery(invitations_and_team_members_query), + where: itm.email != ^user.email, + select: count(itm.email, :distinct) + + Plausible.Repo.one(query) + end + @spec within_limit?(non_neg_integer(), non_neg_integer() | :unlimited) :: boolean() @doc """ Returns whether the limit has been exceeded or not. diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 122e086fa..66f39f8d3 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -49,6 +49,57 @@ defmodule Plausible.Sites do end end + @spec invite(Site.t(), Plausible.Auth.User.t(), String.t(), atom()) :: + {:ok, Plausible.Auth.Invitation.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :already_a_member} + | {:error, {:over_limit, non_neg_integer()}} + def invite(site, inviter, invitee_email, role) do + Repo.transaction(fn -> + do_invite(site, inviter, invitee_email, role) + end) + end + + defp do_invite(site, inviter, invitee_email, role) do + send_invitation_email = fn invitation, invitee -> + invitation = Repo.preload(invitation, [:site, :inviter]) + + email = + if invitee, + do: PlausibleWeb.Email.existing_user_invitation(invitation), + else: PlausibleWeb.Email.new_user_invitation(invitation) + + Plausible.Mailer.send(email) + end + + ensure_new_membership = fn site, invitee -> + if invitee && is_member?(invitee.id, site), do: {:error, :already_a_member}, else: :ok + end + + check_limit = fn site -> + owner = owner_for(site) + usage = Plausible.Billing.Quota.team_member_usage(owner) + limit = Plausible.Billing.Quota.team_member_limit(owner) + + if Plausible.Billing.Quota.within_limit?(usage, limit), + do: :ok, + else: {:error, {:over_limit, limit}} + end + + attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id} + + with :ok <- check_limit.(site), + invitee <- Plausible.Auth.find_user_by(email: invitee_email), + :ok <- ensure_new_membership.(site, invitee), + %Ecto.Changeset{} = changeset <- Plausible.Auth.Invitation.new(attrs), + {:ok, invitation} <- Repo.insert(changeset) do + send_invitation_email.(invitation, invitee) + invitation + else + {:error, cause} -> Repo.rollback(cause) + end + end + @spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil @doc """ Returns the date of the first event of the given site, or `nil` if the site diff --git a/lib/plausible_web/components/billing.ex b/lib/plausible_web/components/billing.ex index fdb5724f1..b97a9d4a0 100644 --- a/lib/plausible_web/components/billing.ex +++ b/lib/plausible_web/components/billing.ex @@ -27,10 +27,18 @@ defmodule PlausibleWeb.Components.Billing do <%= @title %> - <%= Cldr.Number.to_string!(@usage) %> - <%= if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}" %> + <%= render_quota(@usage) %> + <%= if @limit, do: "/ #{render_quota(@limit)}" %> """ end + + defp render_quota(quota) do + case quota do + quota when is_number(quota) -> Cldr.Number.to_string!(quota) + :unlimited -> "∞" + nil -> "" + end + end end diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 416fe6101..5d44be0f2 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -498,6 +498,8 @@ defmodule PlausibleWeb.AuthController do subscription: user.subscription, invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription), theme: user.theme || "system", + team_member_limit: Plausible.Billing.Quota.team_member_limit(user), + team_member_usage: Plausible.Billing.Quota.team_member_usage(user), site_limit: Plausible.Billing.Quota.site_limit(user), site_usage: Plausible.Billing.Quota.site_usage(user), total_pageview_limit: Plausible.Billing.Quota.monthly_pageview_limit(user.subscription), diff --git a/lib/plausible_web/controllers/site/membership_controller.ex b/lib/plausible_web/controllers/site/membership_controller.ex index aed8b695e..ae721ee4f 100644 --- a/lib/plausible_web/controllers/site/membership_controller.ex +++ b/lib/plausible_web/controllers/site/membership_controller.ex @@ -39,62 +39,46 @@ defmodule PlausibleWeb.Site.MembershipController do def invite_member(conn, %{"email" => email, "role" => role}) do site_domain = conn.assigns[:site].domain site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain) - user = Plausible.Auth.find_user_by(email: email) - if user && Sites.is_member?(user.id, site) do - msg = "Cannot send invite because #{user.email} is already a member of #{site.domain}" + case Sites.invite(site, conn.assigns.current_user, email, role) do + {:ok, invitation} -> + conn + |> put_flash( + :success, + "#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article("#{invitation.role}")}" + ) + |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) - render(conn, "invite_member_form.html", - error: msg, - site: site, - layout: {PlausibleWeb.LayoutView, "focus.html"}, - skip_plausible_tracking: true - ) - else - case Repo.insert( - Invitation.new(%{ - email: email, - role: role, - site_id: site.id, - inviter_id: conn.assigns[:current_user].id - }) - ) do - {:ok, invitation} -> - invitation = Repo.preload(invitation, [:site, :inviter]) + {:error, :already_a_member} -> + render(conn, "invite_member_form.html", + error: "Cannot send invite because #{email} is already a member of #{site.domain}", + site: site, + layout: {PlausibleWeb.LayoutView, "focus.html"}, + skip_plausible_tracking: true + ) - email_template = - if user do - PlausibleWeb.Email.existing_user_invitation(invitation) - else - PlausibleWeb.Email.new_user_invitation(invitation) - end + {:error, {:over_limit, limit}} -> + render(conn, "invite_member_form.html", + error: + "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit.", + site: site, + layout: {PlausibleWeb.LayoutView, "focus.html"}, + skip_plausible_tracking: true + ) - Plausible.Mailer.send(email_template) + {:error, %Ecto.Changeset{} = changeset} -> + error_msg = + case changeset.errors[:invitation] do + {"already sent", _} -> + "This invitation has been already sent. To send again, remove it from pending invitations first." - conn - |> put_flash( - :success, - "#{email} has been invited to #{site_domain} as #{PlausibleWeb.SiteView.with_indefinite_article(role)}" - ) - |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) + _ -> + "Something went wrong." + end - {:error, changeset} -> - error_msg = - case changeset.errors[:invitation] do - {"already sent", _} -> - "This invitation has been already sent. To send again, remove it from pending invitations first." - - _ -> - "Something went wrong." - end - - conn - |> put_flash( - :error, - error_msg - ) - |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) - end + conn + |> put_flash(:error, error_msg) + |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) end end diff --git a/lib/plausible_web/templates/auth/user_settings.html.heex b/lib/plausible_web/templates/auth/user_settings.html.heex index c54352634..289bc91a5 100644 --- a/lib/plausible_web/templates/auth/user_settings.html.heex +++ b/lib/plausible_web/templates/auth/user_settings.html.heex @@ -151,6 +151,11 @@ usage={@site_usage} limit={@site_limit} /> + diff --git a/priv/plans_v1.json b/priv/plans_v1.json index 9673f5637..4cfe094f5 100644 --- a/priv/plans_v1.json +++ b/priv/plans_v1.json @@ -6,7 +6,8 @@ "monthly_product_id":"558018", "yearly_cost":"$48", "yearly_product_id":"572810", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -15,7 +16,8 @@ "monthly_product_id":"558745", "yearly_cost":"$96", "yearly_product_id":"590752", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -24,7 +26,8 @@ "monthly_product_id":"597485", "yearly_cost":"$144", "yearly_product_id":"597486", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -33,7 +36,8 @@ "monthly_product_id":"597487", "yearly_cost":"$216", "yearly_product_id":"597488", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -42,7 +46,8 @@ "monthly_product_id":"597642", "yearly_cost":"$384", "yearly_product_id":"597643", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -51,7 +56,8 @@ "monthly_product_id":"597309", "yearly_cost":"$552", "yearly_product_id":"597310", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -60,7 +66,8 @@ "monthly_product_id":"597311", "yearly_cost":"$792", "yearly_product_id":"597312", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -69,7 +76,8 @@ "monthly_product_id":"642352", "yearly_cost":"$1200", "yearly_product_id":"642354", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -78,7 +86,8 @@ "monthly_product_id":"642355", "yearly_cost":"$1800", "yearly_product_id":"642356", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -87,6 +96,7 @@ "monthly_product_id":"650652", "yearly_cost":"$2640", "yearly_product_id":"650653", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" } ] diff --git a/priv/plans_v2.json b/priv/plans_v2.json index c6d193bed..d3643daf4 100644 --- a/priv/plans_v2.json +++ b/priv/plans_v2.json @@ -6,7 +6,8 @@ "monthly_product_id":"654177", "yearly_cost":"$60", "yearly_product_id":"653232", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -15,7 +16,8 @@ "monthly_product_id":"654178", "yearly_cost":"$120", "yearly_product_id":"653234", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -24,7 +26,8 @@ "monthly_product_id":"653237", "yearly_cost":"$200", "yearly_product_id":"653236", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -33,7 +36,8 @@ "monthly_product_id":"653238", "yearly_cost":"$300", "yearly_product_id":"653239", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -42,7 +46,8 @@ "monthly_product_id":"653240", "yearly_cost":"$500", "yearly_product_id":"653242", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -51,7 +56,8 @@ "monthly_product_id":"653253", "yearly_cost":"$700", "yearly_product_id":"653254", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -60,7 +66,8 @@ "monthly_product_id":"653255", "yearly_cost":"$1000", "yearly_product_id":"653256", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -69,7 +76,8 @@ "monthly_product_id":"654181", "yearly_cost":"$1500", "yearly_product_id":"653257", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -78,7 +86,8 @@ "monthly_product_id":"654182", "yearly_cost":"$2250", "yearly_product_id":"653258", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -87,6 +96,7 @@ "monthly_product_id":"654183", "yearly_cost":"$3300", "yearly_product_id":"653259", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" } ] diff --git a/priv/plans_v3.json b/priv/plans_v3.json index c6677d3b7..3fa6d0736 100644 --- a/priv/plans_v3.json +++ b/priv/plans_v3.json @@ -6,7 +6,8 @@ "monthly_product_id":"749342", "yearly_cost":"$90", "yearly_product_id":"749343", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -15,7 +16,8 @@ "monthly_product_id":"749344", "yearly_cost":"$190", "yearly_product_id":"749345", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -24,7 +26,8 @@ "monthly_product_id":"749346", "yearly_cost":"$290", "yearly_product_id":"749347", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -33,7 +36,8 @@ "monthly_product_id":"749348", "yearly_cost":"$490", "yearly_product_id":"749349", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -42,7 +46,8 @@ "monthly_product_id":"749350", "yearly_cost":"$690", "yearly_product_id":"749352", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -51,7 +56,8 @@ "monthly_product_id":"749353", "yearly_cost":"$890", "yearly_product_id":"749355", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -60,7 +66,8 @@ "monthly_product_id":"749356", "yearly_cost":"$1290", "yearly_product_id":"749357", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" }, { "kind":"growth", @@ -69,6 +76,7 @@ "monthly_product_id":"749358", "yearly_cost":"$1690", "yearly_product_id":"749359", - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" } ] diff --git a/priv/sandbox_plans.json b/priv/sandbox_plans.json index 6ff6096e1..483bf2dea 100644 --- a/priv/sandbox_plans.json +++ b/priv/sandbox_plans.json @@ -6,7 +6,8 @@ "monthly_product_id":"63842", "yearly_cost":"$90", "yearly_product_id":"63859", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -15,7 +16,8 @@ "monthly_product_id":"63843", "yearly_cost":"$190", "yearly_product_id":"63860", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -24,7 +26,8 @@ "monthly_product_id":"63844", "yearly_cost":"$290", "yearly_product_id":"63861", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -33,7 +36,8 @@ "monthly_product_id":"63845", "yearly_cost":"$490", "yearly_product_id":"63862", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -42,7 +46,8 @@ "monthly_product_id":"63846", "yearly_cost":"$690", "yearly_product_id":"63863", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -51,7 +56,8 @@ "monthly_product_id":"63847", "yearly_cost":"$890", "yearly_product_id":"63864", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -60,7 +66,8 @@ "monthly_product_id":"63848", "yearly_cost":"$1290", "yearly_product_id":"63865", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"growth", @@ -69,7 +76,8 @@ "monthly_product_id":"63849", "yearly_cost":"$1690", "yearly_product_id":"63866", - "site_limit":10 + "site_limit":10, + "team_member_limit":5 }, { "kind":"business", @@ -78,7 +86,8 @@ "monthly_product_id":"63850", "yearly_cost":"$100", "yearly_product_id":"63867", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -87,7 +96,8 @@ "monthly_product_id":"63851", "yearly_cost":"$200", "yearly_product_id":"63868", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -96,7 +106,8 @@ "monthly_product_id":"63852", "yearly_cost":"$300", "yearly_product_id":"63869", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -105,7 +116,8 @@ "monthly_product_id":"63853", "yearly_cost":"$500", "yearly_product_id":"63870", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -114,7 +126,8 @@ "monthly_product_id":"63854", "yearly_cost":"$700", "yearly_product_id":"63871", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -123,7 +136,8 @@ "monthly_product_id":"63855", "yearly_cost":"$900", "yearly_product_id":"63872", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -132,7 +146,8 @@ "monthly_product_id":"63856", "yearly_cost":"$1300", "yearly_product_id":"63873", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 }, { "kind":"business", @@ -141,6 +156,7 @@ "monthly_product_id":"63857", "yearly_cost":"$1700", "yearly_product_id":"63874", - "site_limit":50 + "site_limit":50, + "team_member_limit":50 } ] diff --git a/priv/unlisted_plans_v1.json b/priv/unlisted_plans_v1.json index e8909e8d1..75e2cf9da 100644 --- a/priv/unlisted_plans_v1.json +++ b/priv/unlisted_plans_v1.json @@ -6,6 +6,7 @@ "yearly_cost":"$4800", "monthly_product_id":null, "monthly_cost":null, - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" } ] diff --git a/priv/unlisted_plans_v2.json b/priv/unlisted_plans_v2.json index 8d825c6a5..9c4e9d477 100644 --- a/priv/unlisted_plans_v2.json +++ b/priv/unlisted_plans_v2.json @@ -6,6 +6,7 @@ "monthly_cost":"$250", "yearly_product_id":null, "yearly_cost":null, - "site_limit":50 + "site_limit":50, + "team_member_limit":"unlimited" } ] diff --git a/test/plausible/billing/quota_test.exs b/test/plausible/billing/quota_test.exs index 89967bfdf..6a883a601 100644 --- a/test/plausible/billing/quota_test.exs +++ b/test/plausible/billing/quota_test.exs @@ -41,9 +41,6 @@ defmodule Plausible.Billing.QuotaTest do test "returns 50 when user in on trial" do user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7)) assert 50 == Quota.site_limit(user) - - user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -7)) - assert 50 == Quota.site_limit(user) end test "returns the subscription limit for enterprise users who have not paid yet" do @@ -193,4 +190,155 @@ defmodule Plausible.Billing.QuotaTest do assert Quota.monthly_pageview_usage(user) == 0 end end + + describe "team_member_usage/1" do + test "returns the number of members in all of the sites the user owns" do + me = insert(:user) + + _site_i_own_1 = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner), + build(:site_membership, user: build(:user), role: :viewer) + ] + ) + + _site_i_own_2 = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner), + build(:site_membership, user: build(:user), role: :admin), + build(:site_membership, user: build(:user), role: :viewer) + ] + ) + + _site_i_own_3 = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner) + ] + ) + + _site_i_have_access = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :viewer), + build(:site_membership, user: build(:user), role: :viewer), + build(:site_membership, user: build(:user), role: :viewer), + build(:site_membership, user: build(:user), role: :viewer) + ] + ) + + assert Quota.team_member_usage(me) == 3 + end + + test "counts the same email address as one team member" do + me = insert(:user) + joe = insert(:user, email: "joe@plausible.test") + + _site_i_own_1 = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner), + build(:site_membership, user: joe, role: :viewer) + ] + ) + + _site_i_own_2 = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner), + build(:site_membership, user: build(:user), role: :admin), + build(:site_membership, user: joe, role: :viewer) + ] + ) + + site_i_own_3 = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)]) + + insert(:invitation, site: site_i_own_3, inviter: me, email: "joe@plausible.test") + + assert Quota.team_member_usage(me) == 2 + end + + test "counts pending invitations as team members" do + me = insert(:user) + member = insert(:user) + + site_i_own = + insert(:site, + memberships: [ + build(:site_membership, user: me, role: :owner), + build(:site_membership, user: member, role: :admin) + ] + ) + + site_i_have_access = + insert(:site, memberships: [build(:site_membership, user: me, role: :admin)]) + + insert(:invitation, site: site_i_own, inviter: me) + insert(:invitation, site: site_i_own, inviter: member) + insert(:invitation, site: site_i_have_access, inviter: me) + + assert Quota.team_member_usage(me) == 3 + end + + test "returns zero when user does not have any site" do + me = insert(:user) + assert Quota.team_member_usage(me) == 0 + end + + test "does not count email report recipients as team members" do + me = insert(:user) + site = insert(:site, memberships: [build(:site_membership, user: me, role: :owner)]) + + insert(:weekly_report, + site: site, + recipients: ["adam@plausible.test", "vini@plausible.test"] + ) + + assert Quota.team_member_usage(me) == 0 + end + end + + describe "team_member_limit/1" do + test "returns unlimited when user is on an old plan" do + user_on_v1 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id)) + user_on_v2 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id)) + user_on_v3 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_plan_id)) + + assert :unlimited == Quota.team_member_limit(user_on_v1) + assert :unlimited == Quota.team_member_limit(user_on_v2) + assert :unlimited == Quota.team_member_limit(user_on_v3) + end + + test "returns unlimited when user is on free_10k plan" do + user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k")) + assert :unlimited == Quota.team_member_limit(user) + end + + test "returns unlimited when user is on an enterprise plan" do + user = insert(:user) + enterprise_plan = insert(:enterprise_plan, user_id: user.id) + + _subscription = + insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id) + + assert :unlimited == Quota.team_member_limit(user) + end + + test "returns 5 when user in on trial" do + user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7)) + assert 5 == Quota.team_member_limit(user) + end + + test "is unlimited for enterprise customers" do + user = + insert(:user, + enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"), + subscription: build(:subscription, paddle_plan_id: "123321") + ) + + assert :unlimited == Quota.team_member_limit(user) + end + end end diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs index 2f69d5501..42c941b64 100644 --- a/test/plausible/site/sites_test.exs +++ b/test/plausible/site/sites_test.exs @@ -1,5 +1,6 @@ defmodule Plausible.SitesTest do use Plausible.DataCase + use Bamboo.Test alias Plausible.Sites @@ -67,4 +68,79 @@ defmodule Plausible.SitesTest do assert Sites.has_stats?(site) end end + + describe "invite/4" do + test "creates an invitation" do + inviter = insert(:user) + invitee = insert(:user) + site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)]) + + assert {:ok, %Plausible.Auth.Invitation{}} = + Sites.invite(site, inviter, invitee.email, :viewer) + end + + test "returns validation errors" do + inviter = insert(:user) + invitee = insert(:user) + site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)]) + + assert {:error, changeset} = Sites.invite(site, inviter, invitee.email, :invalid_role) + assert {"is invalid", _} = changeset.errors[:role] + end + + test "returns error when user is already a member" do + inviter = insert(:user) + invitee = insert(:user) + + site = + insert(:site, + memberships: [ + build(:site_membership, user: inviter, role: :owner), + build(:site_membership, user: invitee, role: :viewer) + ] + ) + + assert {:error, :already_a_member} = Sites.invite(site, inviter, invitee.email, :viewer) + assert {:error, :already_a_member} = Sites.invite(site, inviter, inviter.email, :viewer) + end + + test "sends invitation email for existing users" do + [inviter, invitee] = insert_list(2, :user) + site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)]) + + assert {:ok, %Plausible.Auth.Invitation{}} = + Sites.invite(site, inviter, invitee.email, :viewer) + + assert_email_delivered_with( + to: [nil: invitee.email], + subject: "[Plausible Analytics] You've been invited to #{site.domain}" + ) + end + + test "sends invitation email for new users" do + inviter = insert(:user) + site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)]) + + assert {:ok, %Plausible.Auth.Invitation{}} = + Sites.invite(site, inviter, "vini@plausible.test", :viewer) + + assert_email_delivered_with( + to: [nil: "vini@plausible.test"], + subject: "[Plausible Analytics] You've been invited to #{site.domain}" + ) + end + + test "returns error when owner is over their team member limit" do + [owner, inviter, invitee] = insert_list(3, :user) + + memberships = + [ + build(:site_membership, user: owner, role: :owner), + build(:site_membership, user: inviter, role: :admin) + ] ++ build_list(4, :site_membership) + + site = insert(:site, memberships: memberships) + assert {:error, {:over_limit, 5}} = Sites.invite(site, inviter, invitee.email, :viewer) + end + end end diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs index f87625cb4..8afbefc6b 100644 --- a/test/plausible_web/controllers/site/membership_controller_test.exs +++ b/test/plausible_web/controllers/site/membership_controller_test.exs @@ -31,6 +31,22 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do assert redirected_to(conn) == "/#{site.domain}/settings/people" end + test "fails to create invitation when is over limit", %{conn: conn, user: user} do + memberships = + [build(:site_membership, user: user, role: :owner)] ++ build_list(5, :site_membership) + + site = insert(:site, memberships: memberships) + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "admin" + }) + + assert html_response(conn, 200) =~ + "Your account is limited to 5 team members. You can upgrade your plan to increase this limit." + end + test "fails to create invitation with insufficient permissions", %{conn: conn, user: user} do site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)]) @@ -95,7 +111,13 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do second_member = insert(:user) - site = insert(:site, members: [user, second_member]) + + memberships = [ + build(:site_membership, user: user, role: :owner), + build(:site_membership, user: second_member) + ] + + site = insert(:site, memberships: memberships) conn = post(conn, "/sites/#{site.domain}/memberships/invite", %{ diff --git a/test/support/factory.ex b/test/support/factory.ex index fc74a57bd..591a69918 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -32,7 +32,10 @@ defmodule Plausible.Factory do end def site_membership_factory do - %Plausible.Site.Membership{} + %Plausible.Site.Membership{ + user: build(:user), + role: :viewer + } end def ch_session_factory do