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:
parent
7de63a01ae
commit
fc34357865
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue