Usage & Limits section (#3291)

* Update user_settings template to HEEX

* Move site_limit function to Billing.Quota

* Create Billing.Quota.site_usage function

* Move monthly_pageview_limit function to Billing.Quota

* Create Billing.Quota.monthly_pageview_usage function

* Add "Usage & Limits" section to user settings page

* Apply suggestions from code review
This commit is contained in:
Vini Brasil 2023-08-24 14:22:49 -03:00 committed by GitHub
parent 8c870f7c6b
commit 5d9d05c1f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 750 additions and 536 deletions

View File

@ -108,11 +108,8 @@ defmodule Plausible.Billing do
Timex.diff(user.trial_expiry_date, Timex.today(), :days)
end
def usage(user) do
{pageviews, custom_events} = usage_breakdown(user)
pageviews + custom_events
end
@spec last_two_billing_months_usage(Plausible.Auth.User.t(), Date.t()) ::
{non_neg_integer(), non_neg_integer()}
def last_two_billing_months_usage(user, today \\ Timex.today()) do
{first, second} = last_two_billing_cycles(user, today)
@ -248,7 +245,7 @@ defmodule Plausible.Billing do
case user.grace_period do
%GracePeriod{allowance_required: allowance_required} ->
new_monthly_pageview_limit =
Plausible.Billing.Plans.monthly_pageview_limit(user.subscription)
Plausible.Billing.Quota.monthly_pageview_limit(user.subscription)
if new_monthly_pageview_limit > allowance_required do
user

View File

@ -105,36 +105,7 @@ defmodule Plausible.Billing.Plans do
end)
end
@limit_sites_since ~D[2021-05-05]
@spec site_limit(Plausible.Auth.User.t()) :: non_neg_integer() | :unlimited
@doc """
Returns the limit of sites a user can have.
For enterprise customers, returns :unlimited. The site limit is checked in a
background job so as to avoid service disruption.
"""
def site_limit(user) do
cond do
Application.get_env(:plausible, :is_selfhost) -> :unlimited
Timex.before?(user.inserted_at, @limit_sites_since) -> :unlimited
true -> get_site_limit_from_plan(user)
end
end
@site_limit_for_trials 50
@site_limit_for_free_10k 50
defp get_site_limit_from_plan(user) do
user = Plausible.Users.with_subscription(user)
case get_subscription_plan(user.subscription) do
%Plausible.Billing.EnterprisePlan{} -> :unlimited
%Plausible.Billing.Plan{site_limit: site_limit} -> site_limit
:free_10k -> @site_limit_for_free_10k
nil -> @site_limit_for_trials
end
end
defp get_subscription_plan(subscription) do
def get_subscription_plan(subscription) do
if subscription && subscription.paddle_plan_id == "free_10k" do
:free_10k
else
@ -159,25 +130,6 @@ defmodule Plausible.Billing.Plans do
end
end
@spec monthly_pageview_limit(Plausible.Billing.Subscription.t()) :: non_neg_integer() | nil
def monthly_pageview_limit(subscription) do
case get_subscription_plan(subscription) do
%Plausible.Billing.EnterprisePlan{monthly_pageview_limit: limit} ->
limit
%Plausible.Billing.Plan{monthly_pageview_limit: limit} ->
limit
:free_10k ->
10_000
_any ->
Sentry.capture_message("Unknown monthly pageview limit for plan",
extra: %{paddle_plan_id: subscription.paddle_plan_id}
)
end
end
defp get_enterprise_plan(nil), do: nil
defp get_enterprise_plan(%Plausible.Billing.Subscription{} = subscription) do

View File

@ -0,0 +1,89 @@
defmodule Plausible.Billing.Quota do
@moduledoc """
This module provides functions to work with plans usage and limits.
"""
alias Plausible.Billing.Plans
@limit_sites_since ~D[2021-05-05]
@spec site_limit(Plausible.Auth.User.t()) :: non_neg_integer() | :unlimited
@doc """
Returns the limit of sites a user can have.
For enterprise customers, returns :unlimited. The site limit is checked in a
background job so as to avoid service disruption.
"""
def site_limit(user) do
cond do
Application.get_env(:plausible, :is_selfhost) -> :unlimited
Timex.before?(user.inserted_at, @limit_sites_since) -> :unlimited
true -> get_site_limit_from_plan(user)
end
end
@site_limit_for_trials 50
@site_limit_for_free_10k 50
defp get_site_limit_from_plan(user) do
user = Plausible.Users.with_subscription(user)
case Plans.get_subscription_plan(user.subscription) do
%Plausible.Billing.EnterprisePlan{} -> :unlimited
%Plausible.Billing.Plan{site_limit: site_limit} -> site_limit
:free_10k -> @site_limit_for_free_10k
nil -> @site_limit_for_trials
end
end
@spec site_usage(Plausible.Auth.User.t()) :: non_neg_integer()
@doc """
Returns the number of sites the given user owns.
"""
def site_usage(user) do
Plausible.Sites.owned_sites_count(user)
end
@monthly_pageview_limit_for_free_10k 10_000
@spec monthly_pageview_limit(Plausible.Billing.Subscription.t()) ::
non_neg_integer() | :unlimited
@doc """
Returns the limit of pageviews for a subscription.
"""
def monthly_pageview_limit(subscription) do
case Plans.get_subscription_plan(subscription) do
%Plausible.Billing.EnterprisePlan{monthly_pageview_limit: limit} ->
limit
%Plausible.Billing.Plan{monthly_pageview_limit: limit} ->
limit
:free_10k ->
@monthly_pageview_limit_for_free_10k
_any ->
Sentry.capture_message("Unknown monthly pageview limit for plan",
extra: %{paddle_plan_id: subscription && subscription.paddle_plan_id}
)
:unlimited
end
end
@spec monthly_pageview_usage(Plausible.Auth.User.t()) :: non_neg_integer()
@doc """
Returns the amount of pageviews sent by the sites the user owns in last 30 days.
"""
def monthly_pageview_usage(user) do
user
|> Plausible.Billing.usage_breakdown()
|> Tuple.sum()
end
@spec within_limit?(non_neg_integer(), non_neg_integer() | :unlimited) :: boolean()
@doc """
Returns whether the limit has been exceeded or not.
"""
def within_limit?(usage, limit) do
if limit == :unlimited, do: true, else: usage < limit
end
end

View File

@ -13,11 +13,12 @@ defmodule Plausible.Sites do
Ecto.Multi.new()
|> Ecto.Multi.run(:limit, fn _, _ ->
case {Plausible.Billing.Plans.site_limit(user), owned_sites_count(user)} do
{:unlimited, actual} -> {:ok, actual}
{limit, actual} when actual >= limit -> {:error, limit}
{_limit, actual} -> {:ok, actual}
end
limit = Plausible.Billing.Quota.site_limit(user)
usage = Plausible.Billing.Quota.site_usage(user)
if Plausible.Billing.Quota.within_limit?(usage, limit),
do: {:ok, usage},
else: {:error, limit}
end)
|> Ecto.Multi.insert(:site, site_changeset)
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
@ -111,8 +112,9 @@ defmodule Plausible.Sites do
def has_goals?(site) do
Repo.exists?(
from g in Plausible.Goal,
from(g in Plausible.Goal,
where: g.site_id == ^site.id
)
)
end
@ -130,9 +132,10 @@ defmodule Plausible.Sites do
def role(user_id, site) do
Repo.one(
from sm in Site.Membership,
from(sm in Site.Membership,
where: sm.user_id == ^user_id and sm.site_id == ^site.id,
select: sm.role
)
)
end
@ -167,11 +170,12 @@ defmodule Plausible.Sites do
def owner_for(site) do
Repo.one(
from u in Plausible.Auth.User,
from(u in Plausible.Auth.User,
join: sm in Site.Membership,
on: sm.user_id == u.id,
where: sm.site_id == ^site.id,
where: sm.role == :owner
)
)
end
end

View File

@ -0,0 +1,36 @@
defmodule PlausibleWeb.Components.Billing do
@moduledoc false
use Phoenix.Component
slot(:inner_block, required: true)
attr(:rest, :global)
def usage_and_limits_table(assigns) do
~H"""
<table class="min-w-full text-gray-900 dark:text-gray-100" {@rest}>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
<%= render_slot(@inner_block) %>
</tbody>
</table>
"""
end
attr(:title, :string, required: true)
attr(:usage, :any, required: true)
attr(:limit, :integer, default: nil)
attr(:pad, :boolean, default: false)
attr(:rest, :global)
def usage_and_limits_row(assigns) do
~H"""
<tr {@rest}>
<td class={["py-4 text-sm whitespace-nowrap text-left", @pad && "pl-6"]}><%= @title %></td>
<td class="py-4 text-sm whitespace-nowrap text-right">
<%= Cldr.Number.to_string!(@usage) %>
<%= if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}" %>
</td>
</tr>
"""
end
end

View File

@ -489,8 +489,8 @@ defmodule PlausibleWeb.AuthController do
end
defp render_settings(conn, changeset) do
user = conn.assigns[:current_user]
{usage_pageviews, usage_custom_events} = Plausible.Billing.usage_breakdown(user)
user = Plausible.Users.with_subscription(conn.assigns[:current_user])
{pageview_usage, custom_event_usage} = Plausible.Billing.usage_breakdown(user)
render(conn, "user_settings.html",
user: user |> Repo.preload(:api_keys),
@ -498,8 +498,12 @@ defmodule PlausibleWeb.AuthController do
subscription: user.subscription,
invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription),
theme: user.theme || "system",
usage_pageviews: usage_pageviews,
usage_custom_events: usage_custom_events
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),
total_pageview_usage: pageview_usage + custom_event_usage,
custom_event_usage: custom_event_usage,
pageview_usage: pageview_usage
)
end

View File

@ -28,7 +28,7 @@ defmodule PlausibleWeb.BillingController do
true ->
render(conn, "upgrade.html",
skip_plausible_tracking: true,
usage: Plausible.Billing.usage(user),
usage: Plausible.Billing.Quota.monthly_pageview_usage(user),
user: user,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)

View File

@ -51,27 +51,24 @@ defmodule PlausibleWeb.SiteController do
def new(conn, _params) do
current_user = conn.assigns[:current_user]
owned_site_count = Plausible.Sites.owned_sites_count(current_user)
{site_limit, is_at_limit} =
case Plausible.Billing.Plans.site_limit(current_user) do
:unlimited -> {:unlimited, false}
limit when is_integer(limit) -> {limit, owned_site_count >= limit}
end
limit = Plausible.Billing.Quota.site_limit(current_user)
usage = Plausible.Billing.Quota.site_usage(current_user)
within_limit? = Plausible.Billing.Quota.within_limit?(usage, limit)
render(conn, "new.html",
changeset: Plausible.Site.changeset(%Plausible.Site{}),
is_first_site: owned_site_count == 0,
is_at_limit: is_at_limit,
site_limit: site_limit,
is_first_site: usage == 0,
is_at_limit: !within_limit?,
site_limit: limit,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def create_site(conn, %{"site" => site_params}) do
user = conn.assigns[:current_user]
site_count = Plausible.Sites.owned_sites_count(user)
is_first_site = site_count == 0
usage = Plausible.Billing.Quota.site_usage(user)
is_first_site = usage == 0
case Sites.create(user, site_params) do
{:ok, %{site: site}} ->

View File

@ -1,281 +0,0 @@
<%= if !Application.get_env(:plausible, :is_selfhost) do %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Subscription Plan</h2>
<%= if @subscription do %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-bold leading-5 <%= subscription_colors(@subscription.status) %>">
<%= present_subscription_status(@subscription.status) %>
</span>
<% end %>
</div>
<div class="my-4 border-b border-gray-400"></div>
<%= if @subscription && @subscription.status == "deleted" do %>
<div class="p-2 bg-red-100 rounded-lg sm:p-3">
<div class="flex flex-wrap items-center justify-between">
<div class="flex items-center flex-1 w-0">
<svg class="w-6 h-6 text-red-800" viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="ml-3 font-medium text-red-800">
<%= if @subscription.next_bill_date && Timex.compare(@subscription.next_bill_date, Timex.today()) >= 0 do %>
Your subscription is cancelled but you have access to your stats until <%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %>. Upgrade below to make sure you don't lose access.
<% else %>
Your subscription is cancelled. Upgrade below to get access to your stats again.
<% end %>
</p>
</div>
</div>
</div>
<% end %>
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
<%= if @subscription do %>
<div class="py-2 text-xl font-medium dark:text-gray-100"><%= subscription_quota(@subscription) %> pageviews</div>
<%= case @subscription.status do %>
<% "active" -> %>
<%= link("Change plan", to: "/billing/change-plan", class: "text-sm text-indigo-500 font-medium") %>
<% "past_due" -> %>
<span class="text-sm font-medium text-gray-600 dark:text-gray-400" tooltip="Please update your billing details before changing plans">Change plan</span>
<% _ -> %>
<% end %>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">Free trial</div>
<%= link("Upgrade", to: "/billing/upgrade", class: "text-sm text-indigo-500 font-medium") %>
<% end %>
</div>
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if @subscription && @subscription.status in ["active", "past_due"] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100"><%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %></div>
<%= if @subscription.update_url do %>
<%= link("Update billing info", to: @subscription.update_url, class: "text-sm text-indigo-500 font-medium") %>
<% end %>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && @subscription.status in ["active", "past_due"] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100"><%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></div>
<div class="text-sm font-medium text-gray-600 dark:text-gray-400">(<%= subscription_interval(@subscription) %> billing)</div>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<h3 class="mt-8 text-xl font-bold dark:text-gray-100">Your usage</h3>
<p class="mt-1 text-sm text-gray-500 leading-5 dark:text-gray-200">Last 30 days total usage across all of your sites</p>
<div class="py-2">
<div class="flex flex-col">
<div class="-my-2 sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div>
<table class="min-w-full text-gray-900 divide-y divide-gray-200 dark:text-gray-100">
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200">
<tr>
<td class="py-4 text-sm whitespace-nowrap">
Pageviews
</td>
<td class="py-4 text-sm whitespace-nowrap">
<%= delimit_integer(@usage_pageviews) %>
</td>
</tr>
<tr>
<td class="py-4 text-sm whitespace-nowrap">
Custom events
</td>
<td class="py-4 text-sm whitespace-nowrap">
<%= delimit_integer(@usage_custom_events) %>
</td>
</tr>
<tr>
<td class="py-4 text-sm font-medium whitespace-nowrap">
Total billable pageviews
</td>
<td class="py-4 text-sm font-medium whitespace-nowrap">
<%= delimit_integer(@usage_pageviews + @usage_custom_events) %>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<%= cond do %>
<% @subscription && @subscription.status in ["active", "past_due", "paused"] && @subscription.cancel_url -> %>
<div class="mt-8">
<%= link("Cancel my subscription", to: @subscription.cancel_url, class: "inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150") %>
</div>
<% true -> %>
<div class="mt-8">
<%= link("Upgrade", to: "/billing/upgrade", class: "inline-block px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150") %>
</div>
<% end %>
</div>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<% {:error, :request_failed} -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-center text-black dark:text-gray-100 m-2">
Something went wrong
</p>
</div>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th scope="col" class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Date
</th>
<th scope="col" class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Amount
</th>
<th scope="col" class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Invoice
</th>
</tr>
</thead>
<%= for invoice <- format_invoices(invoice_list) do %>
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200 font-medium">
<%= invoice.date %>
</td>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200">
<%= invoice.currency <> invoice.amount %>
</td>
<td class="py-4 text-sm text-indigo-500">
<%= link("Link", to: invoice.url, target: "_blank" ) %>
</td>
</tr>
</tbody>
<% end %>
</table>
</div>
<% end %>
<% end %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="col-span-4 sm:col-span-2">
<%= label f, :theme, "Theme Selection", class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300" %>
<%= select f, :theme, Plausible.Themes.options(), class: "dark:bg-gray-900 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100 cursor-pointer" %>
</div>
<%= submit "Save", class: "button mt-4" %>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 class="text-xl font-black dark:text-gray-100">Account settings</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= text_input f, :name, class: "shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800" %>
<%= error_tag f, :name %>
</div>
</div>
<div class="my-4">
<%= label f, :email, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-1">
<%= email_input f, :email, class: "shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md" %>
<%= error_tag f, :email %>
</div>
</div>
<%= submit "Save changes", class: "button mt-4" %>
<% end %>
</div>
<div id="api-keys" class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 class="text-xl font-black dark:text-gray-100">API keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user.api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th scope="col" class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100">
Name
</th>
<th scope="col" class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100">
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Revoke</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @user.api_keys do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke", to: "/settings/api-keys/#{api_key.id}", class: "text-red-600 hover:text-red-900", method: :delete, "data-confirm": "Are you sure you want to revoke this key? This action cannot be reversed.") %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<%= link "+ New API key", to: "/settings/api-keys/new", class: "button mt-4" %>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="dark:text-gray-100">Deleting your account removes all sites and stats you've collected</p>
<%= if @subscription && @subscription.status == "active" do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300">Delete my account</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.</p>
<% else %>
<%= link("Delete my account", to: "/me", class: "inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150", method: "delete", data: [confirm: "Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"]) %>
<% end %>
</div>

View File

@ -0,0 +1,377 @@
<%= if !Application.get_env(:plausible, :is_selfhost) do %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Subscription Plan</h2>
<span
:if={@subscription}
class={[
"inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-bold leading-5",
subscription_colors(@subscription.status)
]}
>
<%= present_subscription_status(@subscription.status) %>
</span>
</div>
<div class="my-4 border-b border-gray-400"></div>
<div
:if={@subscription && @subscription.status == "deleted"}
class="p-2 bg-red-100 rounded-lg sm:p-3"
>
<div class="flex flex-wrap items-center justify-between">
<div class="flex items-center flex-1 w-0">
<svg
class="w-6 h-6 text-red-800"
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p class="ml-3 font-medium text-red-800">
<%= if @subscription.next_bill_date && Timex.compare(@subscription.next_bill_date, Timex.today()) >= 0 do %>
Your subscription is cancelled but you have access to your stats until <%= Timex.format!(
@subscription.next_bill_date,
"{Mshort} {D}, {YYYY}"
) %>. Upgrade below to make sure you don't lose access.
<% else %>
Your subscription is cancelled. Upgrade below to get access to your stats again.
<% end %>
</p>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
<%= if @subscription do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= subscription_quota(@subscription) %> pageviews
</div>
<.link
:if={@subscription.status == "active"}
href={Routes.billing_path(@conn, :change_plan_form)}
class="text-sm text-indigo-500 font-medium"
>
Change plan
</.link>
<span
:if={@subscription.status == "past_due"}
class="text-sm text-gray-600 dark:text-gray-400 font-medium"
tooltip="Please update your billing details before changing plans"
>
Change plan
</span>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">Free trial</div>
<.link
href={Routes.billing_path(@conn, :upgrade)}
class="text-sm text-indigo-500 font-medium"
>
Upgrade
</.link>
<% end %>
</div>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if @subscription && @subscription.status in ["active", "past_due"] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= PlausibleWeb.BillingView.present_currency(@subscription.currency_code) %><%= @subscription.next_bill_amount %>
</div>
<%= if @subscription.update_url do %>
<%= link("Update billing info",
to: @subscription.update_url,
class: "text-sm text-indigo-500 font-medium"
) %>
<% end %>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div
class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900"
style="width: 11.75rem;"
>
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && @subscription.status in ["active", "past_due"] do %>
<div class="py-2 text-xl font-medium dark:text-gray-100">
<%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %>
</div>
<div class="text-sm font-medium text-gray-600 dark:text-gray-400">
(<%= subscription_interval(@subscription) %> billing)
</div>
<% else %>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<article class="mt-8">
<h1 class="text-xl font-bold dark:text-gray-100">Usage & Limits</h1>
<h2 class="mt-1 mb-3 text-sm text-gray-500 leading-5 dark:text-gray-200">
Your usage across all of your sites and the limits of your plan
</h2>
<PlausibleWeb.Components.Billing.usage_and_limits_table>
<PlausibleWeb.Components.Billing.usage_and_limits_row
title="Total billable pageviews (last 30 days)"
usage={@total_pageview_usage}
limit={@total_pageview_limit}
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
pad
title="Pageviews"
usage={@pageview_usage}
class="font-normal text-gray-500 dark:text-gray-400"
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
pad
title="Custom events"
usage={@custom_event_usage}
class="font-normal text-gray-500 dark:text-gray-400"
/>
<PlausibleWeb.Components.Billing.usage_and_limits_row
title="Owned sites"
usage={@site_usage}
limit={@site_limit}
/>
</PlausibleWeb.Components.Billing.usage_and_limits_table>
</article>
<%= cond do %>
<% @subscription && @subscription.status in ["active", "past_due", "paused"] && @subscription.cancel_url -> %>
<div class="mt-8">
<%= link("Cancel my subscription",
to: @subscription.cancel_url,
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150"
) %>
</div>
<% true -> %>
<div class="mt-8">
<%= link("Upgrade",
to: "/billing/upgrade",
class:
"inline-block px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:ring active:bg-indigo-700 transition ease-in-out duration-150"
) %>
</div>
<% end %>
</div>
<%= case @invoices do %>
<% {:error, :no_invoices} -> %>
<% {:error, :request_failed} -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="text-center text-black dark:text-gray-100 m-2">
Something went wrong
</p>
</div>
<% {:ok, invoice_list} when is_list(invoice_list) -> %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Invoices</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Date
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Amount
</th>
<th
scope="col"
class="py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Invoice
</th>
</tr>
</thead>
<%= for invoice <- format_invoices(invoice_list) do %>
<tbody class="divide-y divide-gray-200">
<tr>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200 font-medium">
<%= invoice.date %>
</td>
<td class="py-4 text-sm text-gray-800 dark:text-gray-200">
<%= invoice.currency <> invoice.amount %>
</td>
<td class="py-4 text-sm text-indigo-500">
<%= link("Link", to: invoice.url, target: "_blank") %>
</td>
</tr>
</tbody>
<% end %>
</table>
</div>
<% end %>
<% end %>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="col-span-4 sm:col-span-2">
<%= label(f, :theme, "Theme Selection",
class: "block text-sm font-medium leading-5 text-gray-700 dark:text-gray-300"
) %>
<%= select(f, :theme, Plausible.Themes.options(),
class:
"dark:bg-gray-900 mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md dark:text-gray-100 cursor-pointer"
) %>
</div>
<%= submit("Save", class: "button mt-4") %>
<% end %>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 class="text-xl font-black dark:text-gray-100">Account settings</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<%= form_for @changeset, "/settings", [class: "max-w-sm"], fn f -> %>
<div class="my-4">
<%= label(f, :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">
<%= text_input(f, :name,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
) %>
<%= error_tag(f, :name) %>
</div>
</div>
<div class="my-4">
<%= label(f, :email, class: "block text-sm font-medium text-gray-700 dark:text-gray-300") %>
<div class="mt-1">
<%= email_input(f, :email,
class:
"shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md"
) %>
<%= error_tag(f, :email) %>
</div>
</div>
<%= submit("Save changes", class: "button mt-4") %>
<% end %>
</div>
<div
id="api-keys"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">API keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user.api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow dark:border-gray-900 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-900">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Name
</th>
<th
scope="col"
class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-gray-100"
>
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Revoke</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @user.api_keys do %>
<tr class="bg-white dark:bg-gray-800">
<td class="px-6 py-4 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-100 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke",
to: "/settings/api-keys/#{api_key.id}",
class: "text-red-600 hover:text-red-900",
method: :delete,
"data-confirm":
"Are you sure you want to revoke this key? This action cannot be reversed."
) %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<%= link("+ New API key", to: "/settings/api-keys/new", class: "button mt-4") %>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<p class="dark:text-gray-100">
Deleting your account removes all sites and stats you've collected
</p>
<%= if @subscription && @subscription.status == "active" do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300">
Delete my account
</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.
</p>
<% else %>
<%= link("Delete my account",
to: "/me",
class:
"inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150",
method: "delete",
data: [
confirm:
"Deleting your account will also delete all the sites and data that you own. This action cannot be reversed. Are you sure?"
]
) %>
<% end %>
</div>

View File

@ -12,7 +12,7 @@ defmodule PlausibleWeb.AuthView do
def subscription_quota(subscription) do
subscription
|> Plans.monthly_pageview_limit()
|> Plausible.Billing.Quota.monthly_pageview_limit()
|> PlausibleWeb.StatsView.large_number_format()
end

View File

@ -35,7 +35,7 @@ defmodule Plausible.Workers.CheckUsage do
active_subscribers =
Repo.all(
from u in Plausible.Auth.User,
from(u in Plausible.Auth.User,
join: s in Plausible.Billing.Subscription,
on: s.user_id == u.id,
left_join: ep in Plausible.Billing.EnterprisePlan,
@ -48,6 +48,7 @@ defmodule Plausible.Workers.CheckUsage do
least(day_of_month(s.last_bill_date), day_of_month(last_day_of_month(^yesterday))) ==
day_of_month(^yesterday),
preload: [subscription: s, enterprise_plan: ep]
)
)
for subscriber <- active_subscribers do
@ -63,7 +64,7 @@ defmodule Plausible.Workers.CheckUsage do
def check_enterprise_subscriber(subscriber, billing_mod) do
pageview_limit = check_pageview_limit(subscriber, billing_mod)
site_limit = check_site_limit(subscriber)
site_limit = check_site_limit_for_enterprise(subscriber)
case {pageview_limit, site_limit} do
{{:within_limit, _}, {:within_limit, _}} ->
@ -112,25 +113,21 @@ defmodule Plausible.Workers.CheckUsage do
end
defp check_pageview_limit(subscriber, billing_mod) do
monthly_pageview_limit =
case Plausible.Billing.Plans.monthly_pageview_limit(subscriber.subscription) do
monthly_pageview_limit when is_number(monthly_pageview_limit) ->
monthly_pageview_limit * 1.1
nil ->
Sentry.capture_message("Unable to calculate monthly pageview limit",
user: subscriber,
subscription: subscriber.subscription
)
end
limit =
subscriber.subscription
|> Plausible.Billing.Quota.monthly_pageview_limit()
|> Kernel.*(1.1)
|> ceil()
{_, last_cycle} = billing_mod.last_two_billing_cycles(subscriber)
{last_last_cycle_usage, last_cycle_usage} =
billing_mod.last_two_billing_months_usage(subscriber)
exceeded_last_cycle? = last_cycle_usage >= monthly_pageview_limit
exceeded_last_last_cycle? = last_last_cycle_usage >= monthly_pageview_limit
exceeded_last_cycle? = not Plausible.Billing.Quota.within_limit?(last_cycle_usage, limit)
exceeded_last_last_cycle? =
not Plausible.Billing.Quota.within_limit?(last_last_cycle_usage, limit)
if exceeded_last_last_cycle? && exceeded_last_cycle? do
{:over_limit, {last_cycle, last_cycle_usage}}
@ -139,14 +136,14 @@ defmodule Plausible.Workers.CheckUsage do
end
end
defp check_site_limit(subscriber) do
site_limit = subscriber.enterprise_plan.site_limit
total_sites = Plausible.Sites.owned_sites_count(subscriber)
defp check_site_limit_for_enterprise(subscriber) do
limit = subscriber.enterprise_plan.site_limit
usage = Plausible.Billing.Quota.site_usage(subscriber)
if total_sites >= site_limit do
{:over_limit, {total_sites, site_limit}}
if Plausible.Billing.Quota.within_limit?(usage, limit) do
{:within_limit, {usage, limit}}
else
{:within_limit, {total_sites, site_limit}}
{:over_limit, {usage, limit}}
end
end
end

View File

@ -3,52 +3,6 @@ defmodule Plausible.BillingTest do
use Bamboo.Test, shared: true
alias Plausible.Billing
describe "usage" do
test "is 0 with no events" do
user = insert(:user)
assert Billing.usage(user) == 0
end
test "counts the total number of events from all sites the user owns" do
user = insert(:user)
site1 = insert(:site, members: [user])
site2 = insert(:site, members: [user])
populate_stats(site1, [
build(:pageview),
build(:pageview)
])
populate_stats(site2, [
build(:pageview),
build(:event, name: "custom events")
])
assert Billing.usage(user) == 4
end
test "only counts usage from sites where the user is the owner" do
user = insert(:user)
insert(:site,
domain: "site-with-no-views.com",
memberships: [
build(:site_membership, user: user, role: :owner)
]
)
insert(:site,
domain: "test-site.com",
memberships: [
build(:site_membership, user: user, role: :admin)
]
)
assert Billing.usage(user) == 0
end
end
describe "last_two_billing_cycles" do
test "billing on the 1st" do
last_bill_date = ~D[2021-01-01]

View File

@ -32,32 +32,6 @@ defmodule Plausible.Billing.PlansTest do
end
end
describe "monthly_pageview_limit" do
test "is based on the plan if user is on a standard plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
assert Plans.monthly_pageview_limit(user.subscription) == 10_000
end
test "free_10k has 10k monthly_pageview_limit" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert Plans.monthly_pageview_limit(user.subscription) == 10_000
end
test "is based on the enterprise plan if user is on an enterprise plan" do
user = insert(:user)
enterprise_plan =
insert(:enterprise_plan, user_id: user.id, monthly_pageview_limit: 100_000)
subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert Plans.monthly_pageview_limit(subscription) == 100_000
end
end
describe "subscription_interval" do
test "is based on the plan if user is on a standard plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
@ -153,86 +127,4 @@ defmodule Plausible.Billing.PlansTest do
] == Plans.yearly_product_ids()
end
end
describe "site_limit/1" do
test "returns 50 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 50 == Plans.site_limit(user_on_v1)
assert 50 == Plans.site_limit(user_on_v2)
assert 50 == Plans.site_limit(user_on_v3)
end
test "returns 50 when user is on free_10k plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert 50 == Plans.site_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,
monthly_pageview_limit: 100_000,
site_limit: 500
)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert :unlimited == Plans.site_limit(user)
end
test "returns 50 when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert 50 == Plans.site_limit(user)
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: -7))
assert 50 == Plans.site_limit(user)
end
test "returns the subscription limit for enterprise users who have not paid yet" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: @v1_plan_id)
)
assert 50 == Plans.site_limit(user)
end
test "returns 50 for enterprise users who have not upgraded yet and are on trial" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: nil
)
assert 50 == Plans.site_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 == Plans.site_limit(user)
end
test "is unlimited for enterprise customers who are due to change a plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "old-paddle-plan-id"),
subscription: build(:subscription, paddle_plan_id: "old-paddle-plan-id")
)
insert(:enterprise_plan, user_id: user.id, paddle_plan_id: "new-paddle-plan-id")
assert :unlimited == Plans.site_limit(user)
end
end
end

View File

@ -0,0 +1,196 @@
defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true
alias Plausible.Billing.Quota
@v1_plan_id "558018"
@v2_plan_id "654177"
@v3_plan_id "749342"
describe "site_limit/1" do
test "returns 50 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 50 == Quota.site_limit(user_on_v1)
assert 50 == Quota.site_limit(user_on_v2)
assert 50 == Quota.site_limit(user_on_v3)
end
test "returns 50 when user is on free_10k plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert 50 == Quota.site_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,
monthly_pageview_limit: 100_000,
site_limit: 500
)
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert :unlimited == Quota.site_limit(user)
end
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
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: @v1_plan_id)
)
assert 50 == Quota.site_limit(user)
end
test "returns 50 for enterprise users who have not upgraded yet and are on trial" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: nil
)
assert 50 == Quota.site_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.site_limit(user)
end
test "is unlimited for enterprise customers who are due to change a plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "old-paddle-plan-id"),
subscription: build(:subscription, paddle_plan_id: "old-paddle-plan-id")
)
insert(:enterprise_plan, user_id: user.id, paddle_plan_id: "new-paddle-plan-id")
assert :unlimited == Quota.site_limit(user)
end
end
test "site_usage/1 returns the amount of sites the user owns" do
user = insert(:user)
insert_list(3, :site, memberships: [build(:site_membership, user: user, role: :owner)])
insert(:site, memberships: [build(:site_membership, user: user, role: :admin)])
insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
assert Quota.site_usage(user) == 3
end
describe "within_limit?/2" do
test "returns true when quota is not exceeded" do
assert Quota.within_limit?(3, 5)
end
test "returns true when limit is :unlimited" do
assert Quota.within_limit?(10_000, :unlimited)
end
test "returns false when usage is at limit" do
refute Quota.within_limit?(3, 3)
end
test "returns false when usage exceeds the limit" do
refute Quota.within_limit?(10, 3)
end
end
describe "monthly_pageview_limit/1" do
test "is based on the plan if user is on a standard plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
assert Quota.monthly_pageview_limit(user.subscription) == 10_000
end
test "free_10k has 10k monthly_pageview_limit" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: "free_10k"))
assert Quota.monthly_pageview_limit(user.subscription) == 10_000
end
test "is based on the enterprise plan if user is on an enterprise plan" do
user = insert(:user)
enterprise_plan =
insert(:enterprise_plan, user_id: user.id, monthly_pageview_limit: 100_000)
subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert Quota.monthly_pageview_limit(subscription) == 100_000
end
test "does not limit pageviews when user has a pending enterprise plan" do
user = insert(:user)
subscription = insert(:subscription, user_id: user.id, paddle_plan_id: "pending-enterprise")
assert Quota.monthly_pageview_limit(subscription) == :unlimited
end
end
describe "monthly_pageview_usage/1" do
test "is 0 with no events" do
user = insert(:user)
assert Quota.monthly_pageview_usage(user) == 0
end
test "counts the total number of events from all sites the user owns" do
user = insert(:user)
site1 = insert(:site, members: [user])
site2 = insert(:site, members: [user])
populate_stats(site1, [
build(:pageview),
build(:pageview)
])
populate_stats(site2, [
build(:pageview),
build(:event, name: "custom events")
])
assert Quota.monthly_pageview_usage(user) == 4
end
test "only counts usage from sites where the user is the owner" do
user = insert(:user)
insert(:site,
domain: "site-with-no-views.com",
memberships: [
build(:site_membership, user: user, role: :owner)
]
)
insert(:site,
domain: "test-site.com",
memberships: [
build(:site_membership, user: user, role: :admin)
]
)
assert Quota.monthly_pageview_usage(user) == 0
end
end
end