CRM: 24h charts + minor extensions (#5832)

* Display 24h charts in CRM

* Move New Custom Plan button to the top of the page

* Allow custom plan deletion

* Add managed proxy price modifier to custom plan estimation
This commit is contained in:
Adam Rutkowski 2025-10-27 15:29:11 +01:00 committed by GitHub
parent 7de63a01ae
commit fc34357865
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 251 additions and 76 deletions

View File

@ -2,35 +2,32 @@ defmodule Plausible.CustomerSupport.EnterprisePlan do
@moduledoc """
Custom plan price estimation
"""
@spec estimate(
String.t(),
pos_integer(),
pos_integer(),
pos_integer(),
pos_integer(),
list(String.t())
) :: Decimal.t()
def estimate(
billing_interval,
pageviews_per_month,
sites_limit,
team_members_limit,
api_calls_limit,
features
) do
@spec estimate(Keyword.t()) :: Decimal.t()
def estimate(basis) do
basis =
Keyword.validate!(basis, [
:billing_interval,
:pageviews_per_month,
:sites_limit,
:team_members_limit,
:api_calls_limit,
:features,
:managed_proxy_price_modifier
])
pv_rate =
pv_rate(pageviews_per_month)
pv_rate(basis[:pageviews_per_month])
sites_rate =
sites_rate(sites_limit)
sites_rate(basis[:sites_limit])
team_members_rate = team_members_rate(team_members_limit)
team_members_rate = team_members_rate(basis[:team_members_limit])
api_calls_rate =
api_calls_rate(api_calls_limit)
api_calls_rate(basis[:api_calls_limit])
features_rate =
features_rate(features)
features_rate(basis[:features])
cost_per_month =
Decimal.from_float(
@ -38,17 +35,21 @@ defmodule Plausible.CustomerSupport.EnterprisePlan do
sites_rate +
team_members_rate +
api_calls_rate +
features_rate) * 1.0
features_rate + managed_proxy_price_modifier(basis[:managed_proxy_price_modifier])) *
1.0
)
|> Decimal.round(2)
if billing_interval == "monthly" do
if basis[:billing_interval] == "monthly" do
cost_per_month
else
cost_per_month |> Decimal.mult(10) |> Decimal.round(2)
end
end
def managed_proxy_price_modifier(true), do: 199.0
def managed_proxy_price_modifier(_), do: 0
def pv_rate(pvs) when pvs <= 10_000, do: 19
def pv_rate(pvs) when pvs <= 100_000, do: 39
def pv_rate(pvs) when pvs <= 200_000, do: 59

View File

@ -27,7 +27,7 @@ defmodule Plausible.Stats.ConsolidatedView do
end
end
defp empty_24h_intervals(now) do
def empty_24h_intervals(now \\ NaiveDateTime.utc_now()) do
first = NaiveDateTime.add(now, -24, :hour)
{:ok, time} = Time.new(first.hour, 0, 0)
first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time)

View File

@ -58,6 +58,19 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
}
}
</script>
<div class="flex">
<.button
:if={!@show_plan_form?}
id="new-custom-plan"
phx-click="show-plan-form"
phx-target={@myself}
class="ml-auto"
>
New Custom Plan
</.button>
</div>
<div class="mt-4 mb-4 text-gray-900 dark:text-gray-400">
<h1 class="text-xs font-semibold">Usage</h1>
<.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}>
@ -101,11 +114,11 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
<.td class="align-top">
{plan.billing_interval}
</.td>
<.td class="align-top">
<.td class="align-top" data-test-id={"plan-entry-#{plan.paddle_plan_id}"}>
{plan.paddle_plan_id}
<span
:if={(@team.subscription && @team.subscription.paddle_plan_id) == plan.paddle_plan_id}
:if={current_plan?(@team, plan.paddle_plan_id)}
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-xs bg-red-100 text-red-800"
>
CURRENT
@ -129,6 +142,14 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
</.td>
<.td class="align-top">
<.edit_button phx-click="edit-plan" phx-value-id={plan.id} phx-target={@myself} />
<.delete_button
:if={not current_plan?(@team, plan.paddle_plan_id)}
data-test-id={"delete-plan-#{plan.paddle_plan_id}"}
data-confirm="Are you sure you want to delete this plan?"
phx-click="delete-plan"
phx-value-id={plan.id}
phx-target={@myself}
/>
</.td>
</:tbody>
</.table>
@ -200,6 +221,14 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
label={mod.display_name()}
/>
<div class="mt-8 flex align-center gap-x-4">
<.input
type="checkbox"
field={f[:managed_proxy_price_modifier]}
label="Managed proxy"
/>
</div>
<div class="mt-8 flex align-center gap-x-4">
<.input_with_clipboard
id="cost-estimate"
@ -217,21 +246,11 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
</.button>
</div>
</.form>
<.button
:if={!@show_plan_form?}
id="new-custom-plan"
phx-click="show-plan-form"
phx-target={@myself}
>
New Custom Plan
</.button>
</div>
</div>
"""
end
# Event handlers
def handle_event("show-plan-form", _, socket) do
{:noreply, assign(socket, show_plan_form?: true, editing_plan: nil)}
end
@ -248,6 +267,16 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
end
end
def handle_event("delete-plan", %{"id" => plan_id}, socket) do
plan = Plausible.Repo.get(EnterprisePlan, plan_id)
if not current_plan?(socket.assigns.team, plan.paddle_plan_id),
do: Plausible.Repo.delete(plan)
plans = get_plans(socket.assigns.team.id)
{:noreply, assign(socket, plans: plans)}
end
def handle_event("hide-plan-form", _, socket) do
{:noreply, assign(socket, show_plan_form?: false, editing_plan: nil)}
end
@ -256,16 +285,18 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
params = update_features_to_list(params)
form = to_form(EnterprisePlan.changeset(%EnterprisePlan{}, params))
params = sanitize_params(params)
cost_estimate =
Plausible.CustomerSupport.EnterprisePlan.estimate(
params["billing_interval"],
get_int_param(params, "monthly_pageview_limit"),
get_int_param(params, "site_limit"),
get_int_param(params, "team_member_limit"),
get_int_param(params, "hourly_api_request_limit"),
params["features"]
billing_interval: params["billing_interval"],
pageviews_per_month: get_int_param(params, "monthly_pageview_limit"),
sites_limit: get_int_param(params, "site_limit"),
team_members_limit: get_int_param(params, "team_member_limit"),
api_calls_limit: get_int_param(params, "hourly_api_request_limit"),
features: params["features"],
managed_proxy_price_modifier: params["managed_proxy_price_modifier"] == "true"
)
{:noreply, assign(socket, cost_estimate: cost_estimate, plan_form: form)}
@ -426,4 +457,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do
/>
"""
end
defp current_plan?(%{subscription: %{paddle_plan_id: id}}, id), do: true
defp current_plan?(_, _), do: false
end

View File

@ -7,10 +7,27 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
use PlausibleWeb, :live_component
import PlausibleWeb.CustomerSupport.Live
alias Plausible.ConsolidatedView
alias Plausible.Stats
def update(%{team: team}, socket) do
consolidated_views = ConsolidatedView.get(team) |> List.wrap()
{:ok, assign(socket, team: team, consolidated_views: consolidated_views)}
consolidated_view = ConsolidatedView.get(team)
hourly_stats =
with true <- connected?(socket),
{:ok, hourly_stats} <- Stats.ConsolidatedView.safe_overview_24h(consolidated_view) do
hourly_stats.intervals
else
_ ->
Stats.ConsolidatedView.empty_24h_intervals()
|> Enum.map(fn {i, v} -> %{interval: i, visitors: v} end)
end
{:ok,
assign(socket,
team: team,
consolidated_views: List.wrap(consolidated_view),
hourly_stats: hourly_stats
)}
end
def render(assigns) do
@ -29,6 +46,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
<.th>Domain</.th>
<.th>Timezone</.th>
<.th invisible>Dashboard</.th>
<.th invisible>24H</.th>
<.th invisible>Delete</.th>
</:thead>
@ -43,6 +61,15 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.ConsolidatedViews do
Dashboard
</.styled_link>
</.td>
<.td>
<span class="h-[24px] text-indigo-500">
<PlausibleWeb.Live.Components.Visitors.chart
intervals={@hourly_stats}
height={20}
/>
</span>
</.td>
<.td>
<.delete_button
phx-click="delete-consolidated-view"

View File

@ -9,7 +9,24 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
sites = Teams.owned_sites(team, 100)
sites_count = Teams.owned_sites_count(team)
{:ok, assign(socket, team: team, sites: sites, sites_count: sites_count)}
hourly_stats =
if connected?(socket) do
Plausible.Stats.Clickhouse.last_24h_visitors_hourly_intervals(sites)
else
sites
|> Enum.map(fn site ->
{site.domain, Plausible.Stats.Clickhouse.empty_24h_intervals()}
end)
|> Enum.into(%{})
end
{:ok,
assign(socket,
team: team,
sites: sites,
sites_count: sites_count,
hourly_stats: hourly_stats
)}
end
def render(assigns) do
@ -25,6 +42,7 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
<.th>Timezone</.th>
<.th invisible>Settings</.th>
<.th invisible>Dashboard</.th>
<.th invisible>24H</.th>
</:thead>
<:tbody :let={site}>
<.td>
@ -59,6 +77,15 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Sites do
>
Settings
</.styled_link>
<.td>
<span class="h-[24px] text-indigo-500">
<PlausibleWeb.Live.Components.Visitors.chart
:if={is_map(@hourly_stats[site.domain])}
intervals={@hourly_stats[site.domain].intervals}
height={20}
/>
</span>
</.td>
</.td>
</:tbody>
</.table>

View File

@ -23,6 +23,7 @@ defmodule Plausible.Billing.EnterprisePlan do
field :team_member_limit, Plausible.Billing.Ecto.Limit
field :features, {:array, Plausible.Billing.Ecto.Feature}, default: []
field :hourly_api_request_limit, :integer
field :managed_proxy_price_modifier, :boolean, default: false, virtual: true
belongs_to :team, Plausible.Teams.Team

View File

@ -217,7 +217,7 @@ defmodule Plausible.Stats.Clickhouse do
where: ^cutoff_times_condition
end
defp empty_24h_intervals(now) do
def empty_24h_intervals(now \\ NaiveDateTime.utc_now()) do
first = NaiveDateTime.add(now, -24, :hour)
{:ok, time} = Time.new(first.hour, 0, 0)
first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time)

View File

@ -10,12 +10,12 @@ defmodule Plausible.CustomerSupport.EnterprisePlanTest do
test "calculates cost for business plan with monthly billing" do
result =
EnterprisePlan.estimate(
"monthly",
20_000_000,
1000,
30,
1_000,
["sites_api"]
billing_interval: "monthly",
pageviews_per_month: 20_000_000,
sites_limit: 1000,
team_members_limit: 30,
api_calls_limit: 1_000,
features: ["sites_api"]
)
assert result == Decimal.new("1238.00")
@ -24,12 +24,12 @@ defmodule Plausible.CustomerSupport.EnterprisePlanTest do
test "calculates cost for business plan with monthly billing, SSO enabled and extra members" do
result =
EnterprisePlan.estimate(
"monthly",
20_000_000,
1000,
30,
1_000,
["sites_api", "sso"]
billing_interval: "monthly",
pageviews_per_month: 20_000_000,
sites_limit: 1000,
team_members_limit: 30,
api_calls_limit: 1_000,
features: ["sites_api", "sso"]
)
assert result == Decimal.new("1537.00")
@ -38,12 +38,12 @@ defmodule Plausible.CustomerSupport.EnterprisePlanTest do
test "bugfix - from float" do
result =
EnterprisePlan.estimate(
"monthly",
20_000_000,
0,
0,
0,
["sites_api"]
billing_interval: "monthly",
pageviews_per_month: 20_000_000,
sites_limit: 0,
team_members_limit: 0,
api_calls_limit: 0,
features: ["sites_api"]
)
assert result == Decimal.new("738.00")
@ -52,12 +52,12 @@ defmodule Plausible.CustomerSupport.EnterprisePlanTest do
test "Bogdan's example (https://3.basecamp.com/5308029/buckets/26383192/card_tables/cards/8506177450#__recording_8689686259)" do
result =
EnterprisePlan.estimate(
"monthly",
10_000,
500,
15,
600,
[]
billing_interval: "monthly",
pageviews_per_month: 10_000,
sites_limit: 500,
team_members_limit: 15,
api_calls_limit: 600,
features: []
)
assert result == Decimal.new("144.00")
@ -66,16 +66,31 @@ defmodule Plausible.CustomerSupport.EnterprisePlanTest do
test "calculates cost for business plan with yearly billing" do
result =
EnterprisePlan.estimate(
"yearly",
20_000_000,
1000,
30,
1_000,
["sites_api"]
billing_interval: "yearly",
pageviews_per_month: 20_000_000,
sites_limit: 1000,
team_members_limit: 30,
api_calls_limit: 1_000,
features: ["sites_api"]
)
assert result == Decimal.new("12380.00")
end
test "Marko's managed proxy request (https://3.basecamp.com/5308029/buckets/26383192/card_tables/cards/9113635206)" do
result =
EnterprisePlan.estimate(
billing_interval: "monthly",
pageviews_per_month: 10_000,
sites_limit: 500,
team_members_limit: 15,
api_calls_limit: 600,
features: [],
managed_proxy_price_modifier: true
)
assert result == Decimal.new("343.00")
end
end
describe "pv_rate/2" do

View File

@ -647,6 +647,76 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do
) == "5000000"
end
test "current plan is annotated and delete button is available", %{conn: conn, user: user} do
team = team_of(user)
user
|> subscribe_to_enterprise_plan(
team_member_limit: :unlimited,
paddle_plan_id: "plan-current"
)
insert(:enterprise_plan,
team: team,
paddle_plan_id: "plan-another",
monthly_pageview_limit: 1_000_000
)
{:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing))
html = render(lv)
current_selector = ~s|[data-test-id="plan-entry-plan-current"]|
other_selector = ~s|[data-test-id="plan-entry-plan-another"]|
assert element_exists?(html, current_selector)
assert element_exists?(html, other_selector)
current = text_of_element(html, current_selector)
other = text_of_element(html, other_selector)
assert current =~ "CURRENT"
refute other =~ "CURRENT"
refute element_exists?(
html,
~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-current"]|
)
assert element_exists?(
html,
~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-another"]|
)
end
test "plan can be deleted", %{conn: conn, user: user} do
team = team_of(user)
user |> subscribe_to_enterprise_plan(team_member_limit: :unlimited)
inactive_plan =
insert(:enterprise_plan,
team: team,
paddle_plan_id: "plan-another",
monthly_pageview_limit: 1_000_000
)
{:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing))
html = render(lv)
assert element_exists?(html, ~s|[data-test-id="plan-entry-plan-another"]|)
lv
|> element(~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-another"]|)
|> render_click()
html = render(lv)
refute Plausible.Repo.get(Plausible.Billing.EnterprisePlan, inactive_plan.id)
refute element_exists?(html, ~s|[data-test-id="plan-entry-plan-another"]|)
end
defp open_custom_plan(conn, team) do
{:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing))
render(lv)