mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
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:
parent
aadf528459
commit
e65e37afc0
@ -1,6 +1,15 @@
|
|||||||
defmodule Plausible.Billing.EnterprisePlanAdmin do
|
defmodule Plausible.Billing.EnterprisePlanAdmin do
|
||||||
use Plausible.Repo
|
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
|
def search_fields(_schema) do
|
||||||
[
|
[
|
||||||
:paddle_plan_id,
|
:paddle_plan_id,
|
||||||
@ -40,8 +49,42 @@ defmodule Plausible.Billing.EnterprisePlanAdmin do
|
|||||||
|
|
||||||
defp get_user_email(plan), do: plan.user.email
|
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
|
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)
|
Plausible.Billing.EnterprisePlan.changeset(enterprise_plan, attrs)
|
||||||
end
|
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
|
end
|
||||||
|
@ -24,6 +24,83 @@ defmodule Plausible.CrmExtensions do
|
|||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def javascripts(_) do
|
def javascripts(_) do
|
||||||
|
@ -24,6 +24,38 @@ defmodule PlausibleWeb.AdminController do
|
|||||||
|> send_resp(200, html_response)
|
|> send_resp(200, html_response)
|
||||||
end
|
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
|
defp usage_and_limits_html(user, usage, limits, embed?) do
|
||||||
content = """
|
content = """
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -76,6 +76,7 @@ defmodule PlausibleWeb.Router do
|
|||||||
scope "/crm", PlausibleWeb do
|
scope "/crm", PlausibleWeb do
|
||||||
pipe_through :flags
|
pipe_through :flags
|
||||||
get "/auth/user/:user_id/usage", AdminController, :usage
|
get "/auth/user/:user_id/usage", AdminController, :usage
|
||||||
|
get "/billing/user/:user_id/current_plan", AdminController, :current_plan
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
52
test/plausible/billing/enterprise_plan_admin_test.exs
Normal file
52
test/plausible/billing/enterprise_plan_admin_test.exs
Normal 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
|
@ -62,4 +62,59 @@ defmodule PlausibleWeb.AdminControllerTest do
|
|||||||
assert site.stats_start_date == nil
|
assert site.stats_start_date == nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
Loading…
Reference in New Issue
Block a user