From a5e26745aec851d46a5980fb44d719368b38a2b1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:58:29 -0600 Subject: [PATCH] feat: add global pricing defaults UI to admin orgs page --- lib/towerops_web/live/admin/org_live/index.ex | 143 ++++++++++++++- .../live/admin/org_live/index.html.heex | 171 ++++++++++++++++++ .../live/admin/org_live/index_test.exs | 79 ++++++++ 3 files changed, 389 insertions(+), 4 deletions(-) diff --git a/lib/towerops_web/live/admin/org_live/index.ex b/lib/towerops_web/live/admin/org_live/index.ex index f5dcfc8f..cf1b47e0 100644 --- a/lib/towerops_web/live/admin/org_live/index.ex +++ b/lib/towerops_web/live/admin/org_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/admin/org_live/index.html.heex b/lib/towerops_web/live/admin/org_live/index.html.heex index 848e952e..9ff86c4f 100644 --- a/lib/towerops_web/live/admin/org_live/index.html.heex +++ b/lib/towerops_web/live/admin/org_live/index.html.heex @@ -5,6 +5,54 @@

{t_admin("Manage organizations")}

+ +
+
+
+

+ Global Billing Defaults +

+

+ Default pricing for all organizations (can be overridden per-org) +

+
+ +
+ +
+
+
Free Devices
+
+ {@default_free_devices} +
+
+
+
+ Price Per Device +
+
+ ${@default_price}/mo +
+
+
+
+
<.table id="organizations" rows={@organizations}> <:col :let={org} label={t("Name")}>{org.name} @@ -130,5 +178,128 @@
<% end %> + + <%!-- Edit Global Defaults Modal --%> + <%= if @editing_global do %> +
+
+
+
+ +
+

+ Edit Global Billing Defaults +

+ + <.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" + > +
+ + + <%= if error = @global_form.errors[:default_free_devices] do %> +

+ {elem(error, 0)} +

+ <% end %> +
+ +
+ + + <%= if error = @global_form.errors[:default_price_per_device] do %> +

+ {elem(error, 0)} +

+ <% end %> +
+ +
+ + +
+ + + <%!-- Confirmation Dialog --%> + <%= if @show_confirm do %> +
+
+

+ Confirm Price Change +

+

+ This will update {@active_sub_count} active subscriptions to the new price. + Changes take effect at the next billing cycle. +

+
+ + +
+
+
+ <% end %> +
+
+
+ <% end %> diff --git a/test/towerops_web/live/admin/org_live/index_test.exs b/test/towerops_web/live/admin/org_live/index_test.exs index 8bff983e..b3b87724 100644 --- a/test/towerops_web/live/admin/org_live/index_test.exs +++ b/test/towerops_web/live/admin/org_live/index_test.exs @@ -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