feat: add global pricing defaults UI to admin orgs page

This commit is contained in:
Graham McIntire 2026-03-06 13:58:29 -06:00
parent 4f102f4acc
commit a5e26745ae
No known key found for this signature in database
3 changed files with 389 additions and 4 deletions

View file

@ -4,12 +4,18 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
"""
use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Admin
alias Towerops.Billing
alias Towerops.Organizations.Organization
@impl true
def mount(_params, _session, socket) do
orgs = Admin.list_all_organizations()
default_free_devices = Billing.default_free_devices()
default_price = Billing.default_price_per_device()
active_sub_count = count_active_subscriptions()
ip =
case get_connect_info(socket, :peer_data) do
@ -24,17 +30,41 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|> assign(:organizations, orgs)
|> assign(:client_ip, ip)
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
|> assign(:override_form, nil)
|> assign(:default_free_devices, default_free_devices)
|> assign(:default_price, default_price)
|> assign(:active_sub_count, active_sub_count)
|> assign(:editing_global, false)
|> assign(:global_form, nil)
|> assign(:show_confirm, false)
|> assign(:pending_pricing_params, nil)}
end
@impl true
def handle_params(%{"edit_global" => "true"} = _params, _url, socket) do
form =
to_form(%{
"default_free_devices" => socket.assigns.default_free_devices,
"default_price_per_device" => Decimal.to_string(socket.assigns.default_price)
})
{:noreply,
socket
|> assign(:editing_global, true)
|> assign(:global_form, form)
|> assign(:show_confirm, false)
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
end
def handle_params(params, _url, socket) do
case params["edit"] do
nil ->
{:noreply,
socket
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
|> assign(:override_form, nil)
|> assign(:editing_global, false)}
org_id ->
org = Enum.find(socket.assigns.organizations, &(&1.id == org_id))
@ -45,12 +75,14 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
{:noreply,
socket
|> assign(:editing_org, org)
|> assign(:override_form, to_form(changeset))}
|> assign(:override_form, to_form(changeset))
|> assign(:editing_global, false)}
else
{:noreply,
socket
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
|> assign(:override_form, nil)
|> assign(:editing_global, false)}
end
end
end
@ -128,4 +160,107 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
{:noreply, put_flash(socket, :error, t_admin("Failed to delete organization"))}
end
end
def handle_event("edit_global_defaults", _params, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/organizations?edit_global=true")}
end
def handle_event("validate_global_pricing", %{"global_pricing" => params}, socket) do
errors = validate_global_pricing_params(params)
form = to_form(params, errors: errors)
{:noreply, assign(socket, :global_form, form)}
end
def handle_event("save_global_pricing", %{"global_pricing" => params}, socket) do
case validate_global_pricing_params(params) do
[] ->
# Valid - show confirmation
{:noreply,
socket
|> assign(:show_confirm, true)
|> assign(:pending_pricing_params, params)}
errors ->
form = to_form(params, errors: errors)
{:noreply, assign(socket, :global_form, form)}
end
end
def handle_event("confirm_pricing_update", _params, socket) do
params = socket.assigns.pending_pricing_params
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
case Admin.update_global_pricing(params, superuser.id, socket.assigns.client_ip) do
{:ok, result} ->
{:noreply,
socket
|> put_flash(
:info,
"Pricing updated successfully. #{result.succeeded}/#{result.total} subscriptions migrated."
)
|> push_patch(to: ~p"/admin/organizations")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to update pricing: #{inspect(reason)}")}
end
end
def handle_event("cancel_global_edit", _params, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/organizations")}
end
defp validate_global_pricing_params(params) do
errors = []
errors =
case Map.get(params, "default_free_devices") do
nil ->
errors
value ->
case Integer.parse(value) do
{int, _} when int > 0 and int < 10_000 ->
errors
_ ->
[
{:default_free_devices, {"must be greater than 0 and less than 10,000", []}}
| errors
]
end
end
errors =
case Map.get(params, "default_price_per_device") do
nil ->
errors
value ->
case Decimal.parse(value) do
{decimal, _} ->
if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
Decimal.compare(decimal, "999.99") in [:lt, :eq] do
errors
else
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
end
:error ->
[{:default_price_per_device, {"must be a valid decimal", []}} | errors]
end
end
errors
end
defp count_active_subscriptions do
alias Towerops.Repo
Repo.one(
from o in Organization,
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
select: count(o.id)
)
end
end

View file

@ -5,6 +5,54 @@
<p class="text-gray-600 dark:text-gray-400">{t_admin("Manage organizations")}</p>
</div>
<!-- Global Billing Defaults Card -->
<div
class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800"
data-test="global-defaults-card"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
Global Billing Defaults
</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Default pricing for all organizations (can be overridden per-org)
</p>
</div>
<button
type="button"
phx-click="edit_global_defaults"
data-test="edit-global-defaults"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Edit Defaults
</button>
</div>
<dl class="mt-6 grid grid-cols-2 gap-6">
<div>
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">Free Devices</dt>
<dd
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
data-test="default-free-devices"
>
{@default_free_devices}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">
Price Per Device
</dt>
<dd
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
data-test="default-price"
>
${@default_price}/mo
</dd>
</div>
</dl>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.table id="organizations" rows={@organizations}>
<:col :let={org} label={t("Name")}>{org.name}</:col>
@ -130,5 +178,128 @@
</.form>
</div>
<% end %>
<%!-- Edit Global Defaults Modal --%>
<%= if @editing_global do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
data-test="global-defaults-modal"
>
<div class="flex min-h-screen items-center justify-center p-4">
<div
class="fixed inset-0 bg-slate-500 bg-opacity-75"
phx-click="cancel_global_edit"
>
</div>
<div class="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
Edit Global Billing Defaults
</h3>
<.form
for={@global_form}
phx-change="validate_global_pricing"
phx-submit="save_global_pricing"
data-test="global-defaults-form"
class="mt-6 space-y-4"
>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
Free Devices
</label>
<input
type="number"
name="global_pricing[default_free_devices]"
value={@global_form.data["default_free_devices"]}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
/>
<%= if error = @global_form.errors[:default_free_devices] do %>
<p
id="global_pricing_default_free_devices-error"
class="mt-1 text-sm text-red-600"
>
{elem(error, 0)}
</p>
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
Price Per Device (USD/month)
</label>
<input
type="number"
step="0.01"
name="global_pricing[default_price_per_device]"
value={@global_form.data["default_price_per_device"]}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
/>
<%= if error = @global_form.errors[:default_price_per_device] do %>
<p
id="global_pricing_default_price_per_device-error"
class="mt-1 text-sm text-red-600"
>
{elem(error, 0)}
</p>
<% end %>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
phx-click="cancel_global_edit"
data-test="cancel-global-edit"
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Save Changes
</button>
</div>
</.form>
<%!-- Confirmation Dialog --%>
<%= if @show_confirm do %>
<div
class="absolute inset-0 flex items-center justify-center bg-slate-900/50"
data-test="confirm-pricing-change"
>
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h4 class="text-lg font-medium text-slate-900 dark:text-white">
Confirm Price Change
</h4>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
This will update {@active_sub_count} active subscriptions to the new price.
Changes take effect at the next billing cycle.
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
phx-click="cancel_global_edit"
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
>
Cancel
</button>
<button
type="button"
phx-click="confirm_pricing_update"
data-test="confirm-pricing-update"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Confirm Update
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.admin>

View file

@ -165,4 +165,83 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
refute has_element?(view, "#billing-override-form")
end
end
describe "global pricing defaults" do
test "renders global defaults card", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
assert has_element?(view, "[data-test='global-defaults-card']")
assert has_element?(view, "[data-test='default-free-devices']", "10")
assert has_element?(view, "[data-test='default-price']")
end
test "opens edit modal when clicking edit button", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
view
|> element("[data-test='edit-global-defaults']")
|> render_click()
assert has_element?(view, "[data-test='global-defaults-modal']")
assert has_element?(view, "input[name='global_pricing[default_free_devices]']")
assert has_element?(view, "input[name='global_pricing[default_price_per_device]']")
end
test "validates pricing values", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> form("[data-test='global-defaults-form']", %{
global_pricing: %{
default_free_devices: "-5",
default_price_per_device: "1000.00"
}
})
|> render_change()
assert has_element?(view, "#global_pricing_default_free_devices-error")
assert has_element?(view, "#global_pricing_default_price_per_device-error")
end
test "shows confirmation dialog before saving", %{conn: conn, user: user} do
import Towerops.OrganizationsFixtures
_org1 =
organization_fixture(user.id, %{
subscription_status: "active",
stripe_subscription_id: "sub_1"
})
_org2 =
organization_fixture(user.id, %{
subscription_status: "active",
stripe_subscription_id: "sub_2"
})
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> form("[data-test='global-defaults-form']", %{
global_pricing: %{
default_free_devices: "15",
default_price_per_device: "3.00"
}
})
|> render_submit()
# Should show confirmation dialog
assert has_element?(view, "[data-test='confirm-pricing-change']")
end
test "cancels edit without changes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> element("[data-test='cancel-global-edit']")
|> render_click()
refute has_element?(view, "[data-test='global-defaults-modal']")
assert_patched(view, ~p"/admin/organizations")
end
end
end