Implement CRM enterprise plan definition QoL improvements (#4230)

* Implement autoprefill of enterprise plan fields on user change

* Implement sanitizing input attrs in enterprise plan CRM form

* Implement number formatting for monthly pageview limit input in CRM form
This commit is contained in:
Adrian Gruntkowski 2024-06-17 13:11:53 +02:00 committed by GitHub
parent aadf528459
commit e65e37afc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 261 additions and 1 deletions

View File

@ -1,6 +1,15 @@
defmodule Plausible.Billing.EnterprisePlanAdmin do
use Plausible.Repo
@numeric_fields [
"user_id",
"paddle_plan_id",
"monthly_pageview_limit",
"site_limit",
"team_member_limit",
"hourly_api_request_limit"
]
def search_fields(_schema) do
[
:paddle_plan_id,
@ -40,8 +49,42 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
defp get_user_email(plan), do: plan.user.email
def create_changeset(schema, attrs) do
attrs = sanitize_attrs(attrs)
Plausible.Billing.EnterprisePlan.changeset(schema, attrs)
end
def update_changeset(enterprise_plan, attrs) do
attrs = Map.put_new(attrs, "features", [])
attrs =
attrs
|> Map.put_new("features", [])
|> sanitize_attrs()
Plausible.Billing.EnterprisePlan.changeset(enterprise_plan, attrs)
end
defp sanitize_attrs(attrs) do
attrs
|> Enum.map(&clear_attr/1)
|> Enum.reject(&(&1 == ""))
|> Map.new()
end
defp clear_attr({key, value}) when key in @numeric_fields do
value =
value
|> to_string()
|> String.replace(~r/[^0-9-]/, "")
|> String.trim()
{key, value}
end
defp clear_attr({key, value}) when is_binary(value) do
{key, String.trim(value)}
end
defp clear_attr(other) do
other
end
end

View File

@ -24,6 +24,83 @@ defmodule Plausible.CrmExtensions do
""")
]
end
def javascripts(%{assigns: %{context: "billing", resource: "enterprise_plan", changeset: %{}}}) do
[
Phoenix.HTML.raw("""
<script type="text/javascript">
(() => {
const monthlyPageviewLimitField = document.getElementById("enterprise_plan_monthly_pageview_limit")
monthlyPageviewLimitField.type = "input"
monthlyPageviewLimitField.addEventListener("keyup", numberFormatCallback)
monthlyPageviewLimitField.addEventListener("change", numberFormatCallback)
monthlyPageviewLimitField.dispatchEvent(new Event("change"))
function numberFormatCallback(e) {
const numeric = Number(e.target.value.replace(/[^0-9]/g, ''))
const value = numeric > 0 ? new Intl.NumberFormat("en-GB").format(numeric) : ''
e.target.value = value
}
})()
</script>
"""),
Phoenix.HTML.raw("""
<script type="text/javascript">
(async () => {
const userIdField = document.getElementById("enterprise_plan_user_id")
let planRequest
let lastValue = Number(userIdField.value)
let scheduledCheck
userIdField.addEventListener("change", async () => {
if (scheduledCheck) clearTimeout(scheduledCheck)
scheduledCheck = setTimeout(async () => {
const currentValue = Number(userIdField.value)
if (Number.isInteger(currentValue)
&& currentValue > 0
&& currentValue != lastValue
&& !planRequest) {
planRequest = await fetch("/crm/billing/user/" + currentValue + "/current_plan")
const result = await planRequest.json()
fillForm(result)
lastValue = currentValue
planRequest = null
}
}, 300)
})
userIdField.dispatchEvent(new Event("change"))
function fillForm(result) {
[
'billing_interval',
'monthly_pageview_limit',
'site_limit',
'team_member_limit',
'hourly_api_request_limit'
].forEach(name => {
const prefillValue = result[name] || ""
const field = document.getElementById('enterprise_plan_' + name)
field.value = prefillValue
field.dispatchEvent(new Event("change"))
});
['stats_api', 'props', 'funnels', 'revenue_goals'].forEach(feature => {
const checked = result.features.includes(feature)
document.getElementById('enterprise_plan_features_' + feature).checked = checked
});
}
})()
</script>
""")
]
end
end
def javascripts(_) do

View File

@ -24,6 +24,38 @@ defmodule PlausibleWeb.AdminController do
|> send_resp(200, html_response)
end
def current_plan(conn, params) do
user =
params["user_id"]
|> String.to_integer()
|> Plausible.Users.with_subscription()
plan =
case user && user.subscription &&
Plausible.Billing.Plans.get_subscription_plan(user.subscription) do
%{} = plan ->
plan
|> Map.take([
:billing_interval,
:monthly_pageview_limit,
:site_limit,
:team_member_limit,
:hourly_api_request_limit,
:features
])
|> Map.update(:features, [], fn features -> Enum.map(features, & &1.name()) end)
_ ->
%{features: []}
end
json_response = Jason.encode!(plan)
conn
|> put_resp_content_type("application/json")
|> send_resp(200, json_response)
end
defp usage_and_limits_html(user, usage, limits, embed?) do
content = """
<ul>

View File

@ -76,6 +76,7 @@ defmodule PlausibleWeb.Router do
scope "/crm", PlausibleWeb do
pipe_through :flags
get "/auth/user/:user_id/usage", AdminController, :usage
get "/billing/user/:user_id/current_plan", AdminController, :current_plan
end
end

View File

@ -0,0 +1,52 @@
defmodule Plausible.Billing.EnterprisePlanAdminTest do
use Plausible.DataCase, async: true
alias Plausible.Billing.EnterprisePlan
alias Plausible.Billing.EnterprisePlanAdmin
@moduletag :ee_only
test "sanitizes number inputs and whitespace" do
user = insert(:user)
changeset =
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
"user_id" => to_string(user.id),
"paddle_plan_id" => " . 123456 ",
"billing_interval" => "monthly",
"monthly_pageview_limit" => "100,000,000",
"site_limit" => " 10 ",
"team_member_limit" => "-1 ",
"hourly_api_request_limit" => " 1,000",
"features" => ["goals"]
})
assert changeset.valid?
assert changeset.changes.user_id == user.id
assert changeset.changes.paddle_plan_id == "123456"
assert changeset.changes.billing_interval == :monthly
assert changeset.changes.monthly_pageview_limit == 100_000_000
assert changeset.changes.site_limit == 10
assert changeset.changes.hourly_api_request_limit == 1000
assert changeset.changes.features == [Plausible.Billing.Feature.Goals]
end
test "scrubs empty attrs" do
user = insert(:user)
changeset =
EnterprisePlanAdmin.create_changeset(%EnterprisePlan{}, %{
"user_id" => to_string(user.id),
"paddle_plan_id" => " ,. ",
"billing_interval" => "monthly",
"monthly_pageview_limit" => "100,000,000",
"site_limit" => " 10 ",
"team_member_limit" => "-1 ",
"hourly_api_request_limit" => " 1,000",
"features" => ["goals"]
})
refute changeset.valid?
assert {_, validation: :required} = changeset.errors[:paddle_plan_id]
end
end

View File

@ -62,4 +62,59 @@ defmodule PlausibleWeb.AdminControllerTest do
assert site.stats_start_date == nil
end
end
describe "GET /crm/billing/user/:user_id/current_plan" do
setup [:create_user, :log_in]
@tag :ee_only
test "returns 403 if the logged in user is not a super admin", %{conn: conn} do
conn = get(conn, "/crm/billing/user/0/current_plan")
assert response(conn, 403) == "Not allowed"
end
@tag :ee_only
test "returns empty state for non-existent user", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
conn = get(conn, "/crm/billing/user/0/current_plan")
assert json_response(conn, 200) == %{"features" => []}
end
@tag :ee_only
test "returns empty state for user without subscription", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan")
assert json_response(conn, 200) == %{"features" => []}
end
@tag :ee_only
test "returns empty state for user with subscription with non-existent paddle plan ID", %{
conn: conn,
user: user
} do
patch_env(:super_admin_user_ids, [user.id])
insert(:subscription, user: user)
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan")
assert json_response(conn, 200) == %{"features" => []}
end
@tag :ee_only
test "returns plan data for user with subscription", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
insert(:subscription, user: user, paddle_plan_id: "857104")
conn = get(conn, "/crm/billing/user/#{user.id}/current_plan")
assert json_response(conn, 200) == %{
"features" => ["goals"],
"monthly_pageview_limit" => 10_000_000,
"site_limit" => 10,
"team_member_limit" => 3
}
end
end
end