642 lines
18 KiB
Elixir
642 lines
18 KiB
Elixir
defmodule Plausible.Teams.Billing do
|
|
@moduledoc false
|
|
|
|
use Plausible
|
|
|
|
import Ecto.Query
|
|
|
|
alias Plausible.Billing.EnterprisePlan
|
|
alias Plausible.Billing.Plans
|
|
alias Plausible.Billing.Subscription
|
|
alias Plausible.Billing.Subscriptions
|
|
alias Plausible.Repo
|
|
alias Plausible.Teams
|
|
|
|
alias Plausible.Billing.{EnterprisePlan, Feature, Plan, Plans, Quota}
|
|
alias Plausible.Billing.Feature.{Goals, Props, SitesAPI, StatsAPI, SharedLinks}
|
|
|
|
require Plausible.Billing.Subscription.Status
|
|
|
|
@limit_sites_since ~D[2021-05-05]
|
|
|
|
@typep last_30_days_usage() :: %{:last_30_days => Quota.usage_cycle()}
|
|
@typep monthly_pageview_usage() :: Quota.cycles_usage() | last_30_days_usage()
|
|
|
|
def grandfathered_team?(nil), do: false
|
|
|
|
def grandfathered_team?(team) do
|
|
# timestamps were originally rewritten from owner.inserted_at
|
|
Date.before?(team.inserted_at, @limit_sites_since)
|
|
end
|
|
|
|
def get_subscription(nil), do: nil
|
|
|
|
def get_subscription(%Teams.Team{subscription: %Subscription{} = subscription}),
|
|
do: subscription
|
|
|
|
def get_subscription(%Teams.Team{} = team) do
|
|
Teams.with_subscription(team).subscription
|
|
end
|
|
|
|
def change_plan(team, new_plan_id) do
|
|
subscription = active_subscription_for(team)
|
|
plan = Plausible.Billing.Plans.find(new_plan_id)
|
|
|
|
limit_checking_opts =
|
|
if team.allow_next_upgrade_override do
|
|
[ignore_pageview_limit: true]
|
|
else
|
|
[]
|
|
end
|
|
|
|
usage = quota_usage(team)
|
|
|
|
with :ok <-
|
|
Plausible.Billing.Quota.ensure_within_plan_limits(usage, plan, limit_checking_opts),
|
|
do: do_change_plan(subscription, new_plan_id)
|
|
end
|
|
|
|
defp do_change_plan(subscription, new_plan_id) do
|
|
res =
|
|
Plausible.Billing.paddle_api().update_subscription(subscription.paddle_subscription_id, %{
|
|
plan_id: new_plan_id
|
|
})
|
|
|
|
case res do
|
|
{:ok, response} ->
|
|
amount = :erlang.float_to_binary(response["next_payment"]["amount"] / 1, decimals: 2)
|
|
|
|
Subscription.changeset(subscription, %{
|
|
paddle_plan_id: Integer.to_string(response["plan_id"]),
|
|
next_bill_amount: amount,
|
|
next_bill_date: response["next_payment"]["date"]
|
|
})
|
|
|> Repo.update()
|
|
|
|
e ->
|
|
e
|
|
end
|
|
end
|
|
|
|
def enterprise_configured?(nil), do: false
|
|
|
|
def enterprise_configured?(%Teams.Team{} = team) do
|
|
team
|
|
|> Ecto.assoc(:enterprise_plan)
|
|
|> Repo.exists?()
|
|
end
|
|
|
|
def latest_enterprise_plan_with_price(team, customer_ip) do
|
|
enterprise_plan =
|
|
Repo.one!(
|
|
from(e in EnterprisePlan,
|
|
where: e.team_id == ^team.id,
|
|
order_by: [desc: e.inserted_at],
|
|
limit: 1
|
|
)
|
|
)
|
|
|
|
{enterprise_plan, Plausible.Billing.Plans.get_price_for(enterprise_plan, customer_ip)}
|
|
end
|
|
|
|
def has_active_subscription?(nil), do: false
|
|
|
|
def has_active_subscription?(team) do
|
|
team
|
|
|> active_subscription_query()
|
|
|> Repo.exists?()
|
|
end
|
|
|
|
def active_subscription_for(nil), do: nil
|
|
|
|
def active_subscription_for(team) do
|
|
team
|
|
|> active_subscription_query()
|
|
|> Repo.one()
|
|
end
|
|
|
|
@spec check_needs_to_upgrade(Teams.Team.t() | nil, atom()) ::
|
|
{:needs_to_upgrade, :no_active_trial_or_subscription | :grace_period_ended}
|
|
| :no_upgrade_needed
|
|
def check_needs_to_upgrade(team_or_nil, usage_mod \\ Teams.Billing)
|
|
|
|
def check_needs_to_upgrade(nil, _usage_mod),
|
|
do: {:needs_to_upgrade, :no_active_trial_or_subscription}
|
|
|
|
def check_needs_to_upgrade(team, usage_mod) do
|
|
team = Teams.with_subscription(team)
|
|
|
|
cond do
|
|
Plausible.Teams.on_trial?(team) ->
|
|
:no_upgrade_needed
|
|
|
|
not Subscriptions.active?(team.subscription) ->
|
|
{:needs_to_upgrade, :no_active_trial_or_subscription}
|
|
|
|
Teams.GracePeriod.expired?(team) ->
|
|
revise_pageview_usage(team, usage_mod)
|
|
|
|
true ->
|
|
:no_upgrade_needed
|
|
end
|
|
end
|
|
|
|
defp revise_pageview_usage(team, usage_mod) do
|
|
case Plausible.Workers.CheckUsage.check_pageview_usage_two_cycles(team, usage_mod) do
|
|
{:over_limit, _} ->
|
|
{:needs_to_upgrade, :grace_period_ended}
|
|
|
|
{:below_limit, _} ->
|
|
Plausible.Teams.remove_grace_period(team)
|
|
:no_upgrade_needed
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Enterprise plans are always allowed to add more sites (even when
|
|
over limit) to avoid service disruption. Their usage is checked
|
|
in a background job instead (see `check_usage.ex`).
|
|
"""
|
|
def ensure_can_add_new_site(nil) do
|
|
:ok
|
|
end
|
|
|
|
def ensure_can_add_new_site(team) do
|
|
team = Teams.with_subscription(team)
|
|
|
|
case Plans.get_subscription_plan(team.subscription) do
|
|
%EnterprisePlan{} ->
|
|
:ok
|
|
|
|
_ ->
|
|
usage = site_usage(team)
|
|
limit = site_limit(team)
|
|
|
|
if Plausible.Billing.Quota.below_limit?(usage, limit) do
|
|
:ok
|
|
else
|
|
{:error, {:over_limit, limit}}
|
|
end
|
|
end
|
|
end
|
|
|
|
on_ee do
|
|
@site_limit_for_trials 10
|
|
|
|
def site_limit(nil) do
|
|
@site_limit_for_trials
|
|
end
|
|
|
|
def site_limit(team) do
|
|
if grandfathered_team?(team) do
|
|
:unlimited
|
|
else
|
|
get_site_limit_from_plan(team)
|
|
end
|
|
end
|
|
|
|
defp get_site_limit_from_plan(team) do
|
|
team =
|
|
Teams.with_subscription(team)
|
|
|
|
case Plans.get_subscription_plan(team.subscription) do
|
|
%{site_limit: site_limit} -> site_limit
|
|
:free_10k -> 50
|
|
nil -> @site_limit_for_trials
|
|
end
|
|
end
|
|
else
|
|
def site_limit(_team), do: :unlimited
|
|
end
|
|
|
|
@doc """
|
|
Returns the number of sites the given team owns.
|
|
"""
|
|
@spec site_usage(Teams.Team.t()) :: non_neg_integer()
|
|
def site_usage(nil), do: 0
|
|
|
|
def site_usage(team) do
|
|
Teams.owned_sites_count(team)
|
|
end
|
|
|
|
on_ee do
|
|
@team_member_limit_for_trials 3
|
|
|
|
def team_member_limit(nil) do
|
|
@team_member_limit_for_trials
|
|
end
|
|
|
|
def team_member_limit(team) do
|
|
team = Teams.with_subscription(team)
|
|
|
|
case Plans.get_subscription_plan(team.subscription) do
|
|
%{team_member_limit: limit} -> limit
|
|
:free_10k -> :unlimited
|
|
nil -> @team_member_limit_for_trials
|
|
end
|
|
end
|
|
|
|
def solo?(nil), do: true
|
|
|
|
def solo?(team) do
|
|
team_member_limit(team) == 0
|
|
end
|
|
else
|
|
def team_member_limit(_team), do: :unlimited
|
|
|
|
def solo?(_team), do: false
|
|
end
|
|
|
|
@doc """
|
|
Returns a full usage report for the team.
|
|
|
|
### Options
|
|
|
|
* `pending_ownership_site_ids` - a list of site IDs from which to count
|
|
additional usage. This allows us to look at the total usage from pending
|
|
ownerships and owned sites at the same time, which is useful, for example,
|
|
when deciding whether to let the team owner upgrade to a plan, or accept a
|
|
site ownership.
|
|
|
|
* `with_features` - when `true`, the returned map will contain features
|
|
usage. Also counts usage from `pending_ownership_site_ids` if that option
|
|
is given.
|
|
"""
|
|
def quota_usage(team, opts \\ []) do
|
|
team = Teams.with_subscription(team)
|
|
with_features? = Keyword.get(opts, :with_features, false)
|
|
pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, [])
|
|
team_site_ids = Teams.owned_sites_ids(team)
|
|
all_site_ids = pending_site_ids ++ team_site_ids
|
|
|
|
monthly_pageviews = monthly_pageview_usage(team, all_site_ids)
|
|
team_member_usage = team_member_usage(team, pending_ownership_site_ids: pending_site_ids)
|
|
|
|
basic_usage = %{
|
|
monthly_pageviews: monthly_pageviews,
|
|
team_members: team_member_usage,
|
|
sites: length(all_site_ids)
|
|
}
|
|
|
|
if with_features? do
|
|
Map.put(basic_usage, :features, features_usage(team, all_site_ids))
|
|
else
|
|
basic_usage
|
|
end
|
|
end
|
|
|
|
@monthly_pageview_limit_for_free_10k 10_000
|
|
@monthly_pageview_limit_for_trials :unlimited
|
|
|
|
def monthly_pageview_limit(nil) do
|
|
@monthly_pageview_limit_for_trials
|
|
end
|
|
|
|
def monthly_pageview_limit(%Teams.Team{} = team) do
|
|
team = Teams.with_subscription(team)
|
|
monthly_pageview_limit(team.subscription)
|
|
end
|
|
|
|
def monthly_pageview_limit(subscription) do
|
|
case Plans.get_subscription_plan(subscription) do
|
|
%EnterprisePlan{monthly_pageview_limit: limit} ->
|
|
limit
|
|
|
|
%Plan{monthly_pageview_limit: limit} ->
|
|
limit
|
|
|
|
:free_10k ->
|
|
@monthly_pageview_limit_for_free_10k
|
|
|
|
_any ->
|
|
if subscription do
|
|
Sentry.capture_message("Unknown monthly pageview limit for plan",
|
|
extra: %{paddle_plan_id: subscription.paddle_plan_id}
|
|
)
|
|
end
|
|
|
|
@monthly_pageview_limit_for_trials
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Queries the ClickHouse database for the monthly pageview usage. If the given team's
|
|
subscription is `active`, `past_due`, or a `deleted` (but not yet expired), a map
|
|
with the following structure is returned:
|
|
|
|
```elixir
|
|
%{
|
|
current_cycle: usage_cycle(),
|
|
last_cycle: usage_cycle(),
|
|
penultimate_cycle: usage_cycle()
|
|
}
|
|
```
|
|
|
|
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:
|
|
|
|
```elixir
|
|
%{last_30_days: usage_cycle()}
|
|
```
|
|
|
|
Given only a team as input, the usage is queried from across all the sites that the
|
|
team owns. Alternatively, given an optional argument of `site_ids`, the usage from
|
|
across all those sites is queried instead.
|
|
"""
|
|
@spec monthly_pageview_usage(Teams.Team.t(), list() | nil) :: monthly_pageview_usage()
|
|
def monthly_pageview_usage(team, site_ids \\ nil)
|
|
|
|
def monthly_pageview_usage(team, nil) do
|
|
monthly_pageview_usage(team, Teams.owned_sites_ids(team))
|
|
end
|
|
|
|
def monthly_pageview_usage(nil, _site_ids) do
|
|
%{last_30_days: usage_cycle(nil, :last_30_days, [])}
|
|
end
|
|
|
|
def monthly_pageview_usage(team, site_ids) do
|
|
team = Teams.with_subscription(team)
|
|
active_subscription? = Subscriptions.active?(team.subscription)
|
|
|
|
if active_subscription? and team.subscription.last_bill_date != nil do
|
|
[:current_cycle, :last_cycle, :penultimate_cycle]
|
|
|> Task.async_stream(fn cycle ->
|
|
{cycle, usage_cycle(team, cycle, site_ids)}
|
|
end)
|
|
|> Enum.into(%{}, fn {:ok, cycle_usage} -> cycle_usage end)
|
|
else
|
|
%{last_30_days: usage_cycle(team, :last_30_days, site_ids)}
|
|
end
|
|
end
|
|
|
|
@spec team_member_usage(Teams.Team.t(), Keyword.t()) :: non_neg_integer()
|
|
@doc """
|
|
Returns the total count of team members associated with the team's sites.
|
|
|
|
* The given team's owner is not counted as a team member.
|
|
|
|
* Pending invitations (but not ownership transfers) are counted as team
|
|
members even before accepted.
|
|
|
|
* Users are counted uniquely - i.e. even if an account is associated with
|
|
many sites owned by the given user, they still count as one team member.
|
|
|
|
### Options
|
|
|
|
* `exclude_emails` - a list of emails to not count towards the usage. This
|
|
allows us to exclude a user from being counted as a team member when
|
|
checking whether a site invitation can be created for that same user.
|
|
|
|
* `pending_ownership_site_ids` - a list of site IDs from which to count
|
|
additional team member usage. Without this option, usage is queried only
|
|
across sites owned by the given user.
|
|
"""
|
|
def team_member_usage(team, opts \\ [])
|
|
def team_member_usage(nil, _), do: 0
|
|
|
|
def team_member_usage(team, opts) do
|
|
[owner | _] = Repo.preload(team, :owners).owners
|
|
exclude_emails = Keyword.get(opts, :exclude_emails, []) ++ [owner.email]
|
|
|
|
pending_site_ids = Keyword.get(opts, :pending_ownership_site_ids, [])
|
|
|
|
team
|
|
|> query_team_member_emails(pending_site_ids, exclude_emails)
|
|
|> Repo.aggregate(:count)
|
|
end
|
|
|
|
def usage_cycle(team, cycle, owned_site_ids \\ nil, today \\ Date.utc_today())
|
|
|
|
def usage_cycle(team, cycle, nil, today) do
|
|
owned_site_ids = Teams.owned_sites_ids(team)
|
|
usage_cycle(team, cycle, owned_site_ids, today)
|
|
end
|
|
|
|
def usage_cycle(_team, :last_30_days, owned_site_ids, today) do
|
|
date_range = Date.range(Date.shift(today, day: -30), today)
|
|
|
|
{pageviews, custom_events} =
|
|
Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range)
|
|
|
|
%{
|
|
date_range: date_range,
|
|
pageviews: pageviews,
|
|
custom_events: custom_events,
|
|
total: pageviews + custom_events
|
|
}
|
|
end
|
|
|
|
def usage_cycle(team, cycle, owned_site_ids, today) do
|
|
team = Teams.with_subscription(team)
|
|
last_bill_date = team.subscription.last_bill_date
|
|
|
|
normalized_last_bill_date =
|
|
Date.shift(last_bill_date, month: Timex.diff(today, last_bill_date, :months))
|
|
|
|
date_range =
|
|
case cycle do
|
|
:current_cycle ->
|
|
Date.range(
|
|
normalized_last_bill_date,
|
|
Date.shift(normalized_last_bill_date, month: 1, day: -1)
|
|
)
|
|
|
|
:last_cycle ->
|
|
Date.range(
|
|
Date.shift(normalized_last_bill_date, month: -1),
|
|
Date.shift(normalized_last_bill_date, day: -1)
|
|
)
|
|
|
|
:penultimate_cycle ->
|
|
Date.range(
|
|
Date.shift(normalized_last_bill_date, month: -2),
|
|
Date.shift(normalized_last_bill_date, day: -1, month: -1)
|
|
)
|
|
end
|
|
|
|
{pageviews, custom_events} =
|
|
Plausible.Stats.Clickhouse.usage_breakdown(owned_site_ids, date_range)
|
|
|
|
%{
|
|
date_range: date_range,
|
|
pageviews: pageviews,
|
|
custom_events: custom_events,
|
|
total: pageviews + custom_events
|
|
}
|
|
end
|
|
|
|
@spec features_usage(Teams.Team.t() | nil, list() | nil) :: [atom()]
|
|
@doc """
|
|
Given only a team, this function returns the features used across all the
|
|
sites this team owns + StatsAPI if any team user has a configured Stats API key.
|
|
|
|
Given a team, and a list of site_ids, returns the features used by those
|
|
sites instead + StatsAPI if any user in the team has a configured Stats API key.
|
|
|
|
The team can also be passed as `nil`, in which case we will never return
|
|
Stats API as a used feature.
|
|
"""
|
|
def features_usage(team, site_ids \\ nil)
|
|
|
|
def features_usage(nil, nil), do: []
|
|
|
|
def features_usage(%Teams.Team{} = team, nil) do
|
|
owned_site_ids = Teams.owned_sites_ids(team)
|
|
features_usage(team, owned_site_ids)
|
|
end
|
|
|
|
def features_usage(%Teams.Team{} = team, owned_site_ids) when is_list(owned_site_ids) do
|
|
site_scoped_feature_usage = features_usage(nil, owned_site_ids)
|
|
|
|
stats_api_used? =
|
|
Repo.exists?(
|
|
from tm in Plausible.Teams.Membership,
|
|
as: :team_membership,
|
|
where: tm.team_id == ^team.id,
|
|
where:
|
|
exists(
|
|
from ak in Plausible.Auth.ApiKey,
|
|
where: ak.user_id == parent_as(:team_membership).user_id
|
|
)
|
|
)
|
|
|
|
site_scoped_feature_usage =
|
|
if stats_api_used? do
|
|
site_scoped_feature_usage ++ [Feature.StatsAPI]
|
|
else
|
|
site_scoped_feature_usage
|
|
end
|
|
|
|
sites_api_used? =
|
|
Repo.exists?(
|
|
from tm in Plausible.Teams.Membership,
|
|
as: :team_membership,
|
|
where: tm.team_id == ^team.id,
|
|
where:
|
|
exists(
|
|
from ak in Plausible.Auth.ApiKey,
|
|
where: ak.user_id == parent_as(:team_membership).user_id,
|
|
where: "sites:provision:*" in ak.scopes
|
|
)
|
|
)
|
|
|
|
if sites_api_used? do
|
|
site_scoped_feature_usage ++ [SitesAPI]
|
|
else
|
|
site_scoped_feature_usage
|
|
end
|
|
end
|
|
|
|
def features_usage(nil, site_ids) when is_list(site_ids) do
|
|
props_usage_q =
|
|
from s in Plausible.Site,
|
|
where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props)
|
|
|
|
revenue_goals_usage_q =
|
|
from g in Plausible.Goal,
|
|
where: g.site_id in ^site_ids and not is_nil(g.currency)
|
|
|
|
queries =
|
|
on_ee do
|
|
funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids
|
|
|
|
[
|
|
{Feature.Props, props_usage_q},
|
|
{Feature.Funnels, funnels_usage_q},
|
|
{Feature.RevenueGoals, revenue_goals_usage_q},
|
|
{Feature.SiteSegments, Plausible.Segments.get_site_segments_usage_query(site_ids)}
|
|
]
|
|
else
|
|
[
|
|
{Feature.Props, props_usage_q},
|
|
{Feature.RevenueGoals, revenue_goals_usage_q}
|
|
]
|
|
end
|
|
|
|
Enum.reduce(queries, [], fn {feature, query}, acc ->
|
|
if Repo.exists?(query), do: acc ++ [feature], else: acc
|
|
end)
|
|
end
|
|
|
|
defp query_team_member_emails(team, pending_ownership_site_ids, exclude_emails) do
|
|
pending_owner_memberships_q =
|
|
from s in Plausible.Site,
|
|
inner_join: t in assoc(s, :team),
|
|
inner_join: tm in assoc(t, :team_memberships),
|
|
inner_join: u in assoc(tm, :user),
|
|
where: s.id in ^pending_ownership_site_ids,
|
|
where: tm.role == :owner,
|
|
where: u.email not in ^exclude_emails,
|
|
select: %{email: u.email}
|
|
|
|
pending_memberships_q =
|
|
from tm in Teams.Membership,
|
|
inner_join: u in assoc(tm, :user),
|
|
left_join: gm in assoc(tm, :guest_memberships),
|
|
where: gm.site_id in ^pending_ownership_site_ids,
|
|
where: u.email not in ^exclude_emails,
|
|
select: %{email: u.email}
|
|
|
|
pending_invitations_q =
|
|
from ti in Teams.Invitation,
|
|
inner_join: gi in assoc(ti, :guest_invitations),
|
|
where: gi.site_id in ^pending_ownership_site_ids,
|
|
where: ti.email not in ^exclude_emails,
|
|
select: %{email: ti.email}
|
|
|
|
team_memberships_q =
|
|
from tm in Teams.Membership,
|
|
inner_join: u in assoc(tm, :user),
|
|
where: tm.team_id == ^team.id,
|
|
where: u.email not in ^exclude_emails,
|
|
select: %{email: u.email}
|
|
|
|
team_invitations_q =
|
|
from ti in Teams.Invitation,
|
|
where: ti.team_id == ^team.id,
|
|
where: ti.email not in ^exclude_emails,
|
|
select: %{email: ti.email}
|
|
|
|
pending_memberships_q
|
|
|> union(^pending_owner_memberships_q)
|
|
|> union(^pending_invitations_q)
|
|
|> union(^team_memberships_q)
|
|
|> union(^team_invitations_q)
|
|
end
|
|
|
|
def allowed_features_for(nil) do
|
|
[Goals]
|
|
end
|
|
|
|
def allowed_features_for(team) do
|
|
team = Teams.with_subscription(team)
|
|
|
|
case Plans.get_subscription_plan(team.subscription) do
|
|
%EnterprisePlan{features: features} ->
|
|
features ++ [SharedLinks]
|
|
|
|
%Plan{features: features} ->
|
|
features
|
|
|
|
:free_10k ->
|
|
[Goals, Props, StatsAPI, SharedLinks]
|
|
|
|
nil ->
|
|
if Teams.on_trial?(team) do
|
|
Feature.list() -- [SitesAPI]
|
|
else
|
|
[Goals, SharedLinks]
|
|
end
|
|
end
|
|
end
|
|
|
|
defp active_subscription_query(team) do
|
|
from(s in Plausible.Billing.Subscription,
|
|
where:
|
|
s.team_id == ^team.id and s.status == ^Plausible.Billing.Subscription.Status.active(),
|
|
order_by: [desc: s.inserted_at],
|
|
limit: 1
|
|
)
|
|
end
|
|
end
|