From 5fb92dd961f1e08c7f351e5eebe94f853f8520b5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 27 Jan 2026 14:14:25 -0600 Subject: [PATCH] add device/org limit --- lib/towerops/devices.ex | 78 +++++-- lib/towerops/organizations.ex | 37 ++- lib/towerops/organizations/organization.ex | 11 +- .../organizations/subscription_limits.ex | 143 ++++++++++++ lib/towerops_web/live/device_live/form.ex | 35 ++- .../live/device_live/form.html.heex | 81 ++++++- lib/towerops_web/live/device_live/index.ex | 7 +- .../live/device_live/index.html.heex | 133 +++++++---- lib/towerops_web/live/org_live/new.ex | 13 +- lib/towerops_web/live/org_live/new.html.heex | 19 ++ ...add_subscription_plan_to_organizations.exs | 11 + priv/static/changelog.txt | 1 + .../fixtures/organizations_fixtures.ex | 2 +- test/towerops/agents_test.exs | 6 +- test/towerops/devices_test.exs | 146 +++++++++++- .../subscription_limits_test.exs | 213 ++++++++++++++++++ test/towerops/organizations_test.exs | 57 ++++- test/towerops_web/live/agent_live_test.exs | 2 +- test/towerops_web/live/org_live_test.exs | 9 +- test/towerops_web/user_auth_test.exs | 2 +- 20 files changed, 920 insertions(+), 86 deletions(-) create mode 100644 lib/towerops/organizations/subscription_limits.ex create mode 100644 priv/repo/migrations/20260127194059_add_subscription_plan_to_organizations.exs create mode 100644 test/towerops/organizations/subscription_limits_test.exs diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index d2977d50..f7a8a0d9 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -7,7 +7,9 @@ defmodule Towerops.Devices do alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.Event + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Repo + alias Towerops.Sites alias Towerops.Workers.DeviceMonitorWorker alias Towerops.Workers.DevicePollerWorker alias Towerops.Workers.DiscoveryWorker @@ -260,27 +262,69 @@ defmodule Towerops.Devices do @doc """ Creates device. + + ## Options + * `:bypass_limits` - When true, bypasses subscription device limits (for superusers) """ - def create_device(attrs) do - case %DeviceSchema{} - |> DeviceSchema.changeset(attrs) - |> Repo.insert() do - {:ok, device} = result -> - # Start monitoring/polling if enabled - _ = - if device.monitoring_enabled do - DeviceMonitorWorker.start_monitoring(device.id) - end + def create_device(attrs, opts \\ []) do + bypass_limits = Keyword.get(opts, :bypass_limits, false) - _ = - if device.snmp_enabled do - DevicePollerWorker.start_polling(device.id) - end + # Check device quota before insertion (unless bypassing) + with {:ok, changeset} <- check_device_quota(attrs, bypass_limits), + {:ok, device} <- Repo.insert(changeset) do + # Start monitoring/polling if enabled + _ = + if device.monitoring_enabled do + DeviceMonitorWorker.start_monitoring(device.id) + end - result + _ = + if device.snmp_enabled do + DevicePollerWorker.start_polling(device.id) + end - error -> - error + {:ok, device} + end + end + + defp check_device_quota(attrs, bypass_limits) do + changeset = DeviceSchema.changeset(%DeviceSchema{}, attrs) + + if bypass_limits do + {:ok, changeset} + else + do_check_quota(changeset) + end + end + + defp do_check_quota(changeset) do + case Ecto.Changeset.fetch_change(changeset, :site_id) do + {:ok, site_id} -> + validate_device_quota(changeset, site_id) + + :error -> + # No site_id in changeset, let normal validation handle it + {:ok, changeset} + end + end + + defp validate_device_quota(changeset, site_id) do + site = site_id |> Sites.get_site!() |> Repo.preload(:organization) + organization = site.organization + + case SubscriptionLimits.check_device_limit(organization) do + {:ok, :within_limit} -> + {:ok, changeset} + + {:error, :at_limit, _current, max} -> + error_changeset = + Ecto.Changeset.add_error( + changeset, + :base, + "You've reached your plan limit of #{max} devices. Upgrade to add more." + ) + + {:error, error_changeset} end end diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 42855f9c..865639cb 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -11,6 +11,7 @@ defmodule Towerops.Organizations do alias Towerops.Organizations.Membership alias Towerops.Organizations.Organization alias Towerops.Organizations.Policy + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Repo ## Organizations @@ -54,9 +55,43 @@ defmodule Towerops.Organizations do @doc """ Creates an organization and adds the creator as owner. + + ## Options + * `:bypass_limits` - When true, bypasses free organization limit (for superusers) """ @dialyzer {:nowarn_function, create_organization: 2} - def create_organization(attrs, user_id) do + @dialyzer {:nowarn_function, create_organization: 3} + def create_organization(attrs, user_id, opts \\ []) do + bypass_limits = Keyword.get(opts, :bypass_limits, false) + subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free" + + # Check free org limit before creating (unless bypassing or creating non-free org) + with :ok <- check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do + do_create_organization(attrs, user_id) + end + end + + defp check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do + if bypass_limits or subscription_plan != "free" do + :ok + else + if SubscriptionLimits.can_create_free_organization?(user_id) do + :ok + else + changeset = + %Organization{} + |> Organization.changeset(attrs) + |> Ecto.Changeset.add_error( + :base, + "You already have a free organization. Upgrade your existing organization to create additional ones." + ) + + {:error, changeset} + end + end + end + + defp do_create_organization(attrs, user_id) do multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :organization, Organization.changeset(%Organization{}, attrs)) diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index d33277cb..66576404 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -23,6 +23,7 @@ defmodule Towerops.Organizations.Organization do schema "organizations" do field :name, :string field :slug, :string + field :subscription_plan, :string, default: "free" # device) field :snmp_version, :string, default: "2c" @@ -41,6 +42,7 @@ defmodule Towerops.Organizations.Organization do id: Ecto.UUID.t(), name: String.t(), slug: String.t(), + subscription_plan: String.t(), snmp_version: String.t() | nil, snmp_community: String.t() | nil, default_agent_token_id: Ecto.UUID.t() | nil, @@ -55,9 +57,16 @@ defmodule Towerops.Organizations.Organization do @doc false def changeset(organization, attrs) do organization - |> cast(attrs, [:name, :default_agent_token_id, :snmp_version, :snmp_community]) + |> cast(attrs, [ + :name, + :subscription_plan, + :default_agent_token_id, + :snmp_version, + :snmp_community + ]) |> validate_required([:name]) |> validate_length(:name, min: 2, max: 100) + |> validate_inclusion(:subscription_plan, ["free"]) |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> generate_slug() |> validate_required([:slug]) diff --git a/lib/towerops/organizations/subscription_limits.ex b/lib/towerops/organizations/subscription_limits.ex new file mode 100644 index 00000000..2b84c255 --- /dev/null +++ b/lib/towerops/organizations/subscription_limits.ex @@ -0,0 +1,143 @@ +defmodule Towerops.Organizations.SubscriptionLimits do + @moduledoc """ + Subscription plan limits and quota enforcement. + + Currently supports: + - Free plan: 10 devices max, 1 owned organization per user + - Future plans can be added here (per-device pricing, etc.) + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Organizations.Membership + alias Towerops.Organizations.Organization + alias Towerops.Repo + + @free_device_limit 10 + + @doc """ + Returns the device limit for a given subscription plan. + + ## Examples + + iex> device_limit("free") + 10 + + iex> device_limit("paid") + :unlimited + """ + def device_limit("free"), do: @free_device_limit + def device_limit(_other), do: :unlimited + + @doc """ + Checks if an organization is within its device quota. + + Returns `{:ok, :within_limit}` if the organization can add more devices. + Returns `{:error, :at_limit, current, max}` if at or over the limit. + + ## Examples + + iex> check_device_limit(%Organization{subscription_plan: "free"}) + {:ok, :within_limit} + + iex> check_device_limit(%Organization{subscription_plan: "free"}) + {:error, :at_limit, 10, 10} + """ + def check_device_limit(%Organization{} = organization) do + case device_limit(organization.subscription_plan) do + :unlimited -> + {:ok, :within_limit} + + max_devices -> + current = count_organization_devices(organization.id) + + if current >= max_devices do + {:error, :at_limit, current, max_devices} + else + {:ok, :within_limit} + end + end + end + + @doc """ + Returns the current device count and limit for an organization. + + Returns `{current_count, limit}` where limit is either an integer or `:unlimited`. + + ## Examples + + iex> device_quota(%Organization{id: "...", subscription_plan: "free"}) + {5, 10} + + iex> device_quota(%Organization{id: "...", subscription_plan: "paid"}) + {45, :unlimited} + """ + def device_quota(%Organization{} = organization) do + current = count_organization_devices(organization.id) + limit = device_limit(organization.subscription_plan) + {current, limit} + end + + @doc """ + Checks if a user can create a free organization. + + Users are limited to owning 1 free organization. They can create unlimited + paid organizations or be invited as members to unlimited organizations. + + Returns `true` if the user can create another free organization, `false` otherwise. + + ## Examples + + iex> can_create_free_organization?(user_id) + true + + iex> can_create_free_organization?(user_id) + false + """ + def can_create_free_organization?(user_id) do + count_user_owned_free_organizations(user_id) < 1 + end + + @doc """ + Counts how many free organizations a user owns. + + Only counts organizations where the user has the `:owner` role. + Memberships as admin/member/viewer do not count. + + ## Examples + + iex> count_user_owned_free_organizations(user_id) + 1 + + iex> count_user_owned_free_organizations(user_id) + 0 + """ + def count_user_owned_free_organizations(user_id) do + Repo.aggregate( + from(o in Organization, + join: m in Membership, + on: m.organization_id == o.id, + where: m.user_id == ^user_id, + where: m.role == :owner, + where: o.subscription_plan == "free" + ), + :count + ) + end + + @doc """ + Counts total devices for an organization. + + Counts devices across all sites in the organization. + """ + def count_organization_devices(organization_id) do + Repo.aggregate( + from(d in Device, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id + ), + :count + ) + end +end diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 1c13b82d..1708f6a7 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -5,6 +5,7 @@ defmodule ToweropsWeb.DeviceLive.Form do alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Device, as: DeviceSchema + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Sites alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker @@ -19,6 +20,16 @@ defmodule ToweropsWeb.DeviceLive.Form do cloud_pollers = Agents.list_cloud_pollers() all_agents = agents ++ cloud_pollers + # Load device quota + {current_devices, device_limit} = + SubscriptionLimits.device_quota(organization) + + is_at_limit = + case device_limit do + :unlimited -> false + limit -> current_devices >= limit + end + # Redirect to sites page if no sites exist if Enum.empty?(sites) do {:ok, @@ -34,7 +45,12 @@ defmodule ToweropsWeb.DeviceLive.Form do |> assign(:preselected_site_id, params["site_id"]) |> assign(:snmp_test_result, nil) |> assign(:duplicate_device, nil) - |> assign(:non_routable_ip_error, false)} + |> assign(:non_routable_ip_error, false) + |> assign(:device_quota, %{ + current: current_devices, + limit: device_limit, + at_limit: is_at_limit + })} end end @@ -251,13 +267,27 @@ defmodule ToweropsWeb.DeviceLive.Form do snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp" device_params = Map.put(device_params, "snmp_enabled", snmp_enabled) - case Devices.create_device(device_params) do + # Check if user is superuser and bypass limits if so + is_superuser = socket.assigns.current_scope.user.is_superuser + opts = if is_superuser, do: [bypass_limits: true], else: [] + + case Devices.create_device(device_params, opts) do {:ok, device} -> # Handle agent assignment after device creation handle_agent_assignment(device.id, agent_token_id) flash_message = handle_device_creation(device) + # Reload quota after device creation + organization = socket.assigns.organization + {current, limit} = SubscriptionLimits.device_quota(organization) + + is_at_limit = + case limit do + :unlimited -> false + l -> current >= l + end + # Reset form for next device while staying on the add page fresh_attrs = %{ monitoring_enabled: true, @@ -275,6 +305,7 @@ defmodule ToweropsWeb.DeviceLive.Form do socket |> put_flash(:info, flash_message) |> assign(:form, to_form(fresh_changeset)) + |> assign(:device_quota, %{current: current, limit: limit, at_limit: is_at_limit}) |> assign(:snmp_test_result, nil) |> push_event("scroll_to_top", %{})} diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index a3ae1904..fa31a946 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -19,14 +19,79 @@ - <.header> - {@page_title} - <:subtitle> - {if @live_action == :new, - do: "Add new device to monitor", - else: "Update device details"} - - +
+
+ <.header> + {@page_title} + <:subtitle> + {if @live_action == :new, + do: "Add new device to monitor", + else: "Update device details"} + + +
+ + <%= if @live_action == :new do %> + <% percent = + if @device_quota.limit != :unlimited and @device_quota.limit > 0 do + (@device_quota.current / @device_quota.limit * 100) |> trunc() + else + 0 + end %> + + <% badge_class = + cond do + @current_scope.user.is_superuser -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + @device_quota.at_limit -> + "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800" + + percent >= 90 -> + "bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800" + + percent >= 75 -> + "bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800" + + @device_quota.limit == :unlimited -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + true -> + "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800" + end %> + +
+ <%= cond do %> + <% @current_scope.user.is_superuser -> %> + {@device_quota.current} devices + <% @device_quota.limit == :unlimited -> %> + {@device_quota.current} devices + <% true -> %> + {@device_quota.current}/{@device_quota.limit} devices + <% end %> +
+ <% end %> +
+ + + <%= if @live_action == :new and not @device_quota.at_limit and @device_quota.limit != :unlimited and not @current_scope.user.is_superuser do %> + <% remaining = @device_quota.limit - @device_quota.current %> + <%= if remaining <= 3 do %> +
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" /> +
+

+ Approaching device limit +

+

+ You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining. +

+
+
+
+ <% end %> + <% end %>
<.form for={@form} id="device-form" phx-change="validate" phx-submit="save"> diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index 59916185..11c61fea 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -3,6 +3,7 @@ defmodule ToweropsWeb.DeviceLive.Index do use ToweropsWeb, :live_view alias Towerops.Devices + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Sites alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker @@ -14,13 +15,17 @@ defmodule ToweropsWeb.DeviceLive.Index do sites = Sites.list_organization_sites(organization.id) grouped_devices = group_devices_by_site(device) + # Load device quota + {current, limit} = SubscriptionLimits.device_quota(organization) + {:ok, socket |> assign(:page_title, "Devices") |> assign(:device, device) |> assign(:grouped_devices, grouped_devices) |> assign(:has_sites, sites != []) - |> assign(:reorder_mode, false)} + |> assign(:reorder_mode, false) + |> assign(:device_quota, %{current: current, limit: limit})} end @impl true diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index ab4d1338..95f911a2 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -5,51 +5,96 @@ active_page="devices" timezone={@timezone} > - <.header> - {@page_title} - <:subtitle>Monitor and manage all your network devices - <:actions> - <%= if @device != [] do %> - <%= if @reorder_mode do %> - <.button - type="button" - phx-click="reset_order" - data-confirm="Reset all sites and devices to alphabetical order?" - > - <.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order - - <.button - type="button" - phx-click="toggle_reorder_mode" - variant="primary" - > - <.icon name="hero-check" class="h-4 w-4" /> Done - - <% else %> - <.button - type="button" - phx-click="toggle_reorder_mode" - > - <.icon name="hero-bars-3" class="h-4 w-4" /> Reorder - - <.button - type="button" - phx-click="force_rediscover_all" - data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?" - > - <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All - - <% end %> +
+
+ <.header> + {@page_title} + <:subtitle>Monitor and manage all your network devices + +
+ + <% percent = + if @device_quota.limit != :unlimited and @device_quota.limit > 0 do + (@device_quota.current / @device_quota.limit * 100) |> trunc() + else + 0 + end %> + + <% badge_class = + cond do + @current_scope.user.is_superuser -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + @device_quota.limit != :unlimited and @device_quota.current >= @device_quota.limit -> + "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800" + + percent >= 90 -> + "bg-orange-50 text-orange-800 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800" + + percent >= 75 -> + "bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800" + + @device_quota.limit == :unlimited -> + "bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400" + + true -> + "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800" + end %> + +
+ <%= cond do %> + <% @current_scope.user.is_superuser -> %> + {@device_quota.current} devices + <% @device_quota.limit == :unlimited -> %> + {@device_quota.current} devices + <% true -> %> + {@device_quota.current}/{@device_quota.limit} devices <% end %> - <.button - :if={@has_sites} - navigate={~p"/devices/new"} - variant="primary" - > - <.icon name="hero-plus" class="h-5 w-5" /> New Device - - - +
+
+ +
+ <%= if @device != [] do %> + <%= if @reorder_mode do %> + <.button + type="button" + phx-click="reset_order" + data-confirm="Reset all sites and devices to alphabetical order?" + > + <.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order + + <.button + type="button" + phx-click="toggle_reorder_mode" + variant="primary" + > + <.icon name="hero-check" class="h-4 w-4" /> Done + + <% else %> + <.button + type="button" + phx-click="toggle_reorder_mode" + > + <.icon name="hero-bars-3" class="h-4 w-4" /> Reorder + + <.button + type="button" + phx-click="force_rediscover_all" + data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?" + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All + + <% end %> + <% end %> + + <.button + :if={@has_sites} + navigate={~p"/devices/new"} + variant="primary" + > + <.icon name="hero-plus" class="h-5 w-5" /> New Device + +
diff --git a/lib/towerops_web/live/org_live/new.ex b/lib/towerops_web/live/org_live/new.ex index c0519cd4..041b11f1 100644 --- a/lib/towerops_web/live/org_live/new.ex +++ b/lib/towerops_web/live/org_live/new.ex @@ -4,15 +4,22 @@ defmodule ToweropsWeb.OrgLive.New do alias Towerops.Organizations alias Towerops.Organizations.Organization + alias Towerops.Organizations.SubscriptionLimits @impl true def mount(_params, _session, socket) do changeset = Organizations.change_organization(%Organization{}) + user = socket.assigns.current_scope.user + + can_create_free = SubscriptionLimits.can_create_free_organization?(user.id) + free_count = SubscriptionLimits.count_user_owned_free_organizations(user.id) {:ok, socket |> assign(:page_title, "New Organization") - |> assign(:form, to_form(changeset))} + |> assign(:form, to_form(changeset)) + |> assign(:can_create_free, can_create_free) + |> assign(:free_org_count, free_count)} end @impl true @@ -28,8 +35,10 @@ defmodule ToweropsWeb.OrgLive.New do @impl true def handle_event("save", %{"organization" => org_params}, socket) do user = socket.assigns.current_scope.user + is_superuser = user.is_superuser + opts = if is_superuser, do: [bypass_limits: true], else: [] - case Organizations.create_organization(org_params, user.id) do + case Organizations.create_organization(org_params, user.id, opts) do {:ok, organization} -> {:noreply, socket diff --git a/lib/towerops_web/live/org_live/new.html.heex b/lib/towerops_web/live/org_live/new.html.heex index 9d98d58f..538d8b23 100644 --- a/lib/towerops_web/live/org_live/new.html.heex +++ b/lib/towerops_web/live/org_live/new.html.heex @@ -9,6 +9,25 @@ <:subtitle>Create a new organization to manage your sites and devices + <%= if not @can_create_free do %> +
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" /> +
+

+ Free organization limit reached +

+

+ You already have {@free_org_count} free {if @free_org_count == 1, + do: "organization", + else: "organizations"}. + To create additional organizations, you'll need to upgrade your existing organization to a paid plan. +

+
+
+
+ <% end %> +
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label="Organization Name" required /> diff --git a/priv/repo/migrations/20260127194059_add_subscription_plan_to_organizations.exs b/priv/repo/migrations/20260127194059_add_subscription_plan_to_organizations.exs new file mode 100644 index 00000000..bb9f9864 --- /dev/null +++ b/priv/repo/migrations/20260127194059_add_subscription_plan_to_organizations.exs @@ -0,0 +1,11 @@ +defmodule Towerops.Repo.Migrations.AddSubscriptionPlanToOrganizations do + use Ecto.Migration + + def change do + alter table(:organizations) do + add :subscription_plan, :string, default: "free", null: false + end + + create index(:organizations, [:subscription_plan]) + end +end diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index e966d297..f01a6b76 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -6,6 +6,7 @@ Devices Working * Infrastructure improvements * Bug fix: poller agent assignment on devices * Feature: improve Ubiquiti LTU device handling +* First pass at subscription limits 2025-01-26 * Cloud poller improvements diff --git a/test/support/fixtures/organizations_fixtures.ex b/test/support/fixtures/organizations_fixtures.ex index 3c8c20cb..887c5fa5 100644 --- a/test/support/fixtures/organizations_fixtures.ex +++ b/test/support/fixtures/organizations_fixtures.ex @@ -17,7 +17,7 @@ defmodule Towerops.OrganizationsFixtures do def organization_fixture(user_id, attrs \\ %{}) do attrs = valid_organization_attributes(attrs) - {:ok, organization} = Organizations.create_organization(attrs, user_id) + {:ok, organization} = Organizations.create_organization(attrs, user_id, bypass_limits: true) organization end diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs index 818d6bfa..83b5d892 100644 --- a/test/towerops/agents_test.exs +++ b/test/towerops/agents_test.exs @@ -36,7 +36,7 @@ defmodule Towerops.AgentsTest do describe "list_organization_agent_tokens/1" do test "returns all agent tokens for organization", %{organization: org1, user: user} do - {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id) + {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id, bypass_limits: true) {:ok, token1, _} = Agents.create_agent_token(org1.id, "Agent 1") {:ok, token2, _} = Agents.create_agent_token(org1.id, "Agent 2") @@ -1326,8 +1326,8 @@ defmodule Towerops.AgentsTest do describe "cloud poller polling targets" do setup do user = user_fixture() - {:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id) - {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id) + {:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id, bypass_limits: true) + {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true) {:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id}) {:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id}) diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs index 7eb8f7a2..5d69e687 100644 --- a/test/towerops/devices_test.exs +++ b/test/towerops/devices_test.exs @@ -1031,7 +1031,8 @@ defmodule Towerops.EquipmentTest do site_id: site.id } - assert {:ok, %Device{} = device} = Devices.create_device(attrs) + # Use bypass_limits since property tests may create > 10 devices + assert {:ok, %Device{} = device} = Devices.create_device(attrs, bypass_limits: true) assert device.ip_address == ip_address assert device.status == :unknown end @@ -1150,4 +1151,147 @@ defmodule Towerops.EquipmentTest do end end end + + describe "device creation with subscription limits" do + import Towerops.AccountsFixtures + + setup do + user = user_fixture() + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + %{organization: organization, site: site, user: user} + end + + test "blocks device creation when at 10-device limit", %{site: site} do + # Create 10 devices (the limit) + for i <- 1..10 do + {:ok, _device} = + Devices.create_device(%{ + name: "Device #{i}", + ip_address: "192.168.1.#{i}", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + # 11th device should be blocked + assert {:error, changeset} = + Devices.create_device(%{ + name: "Device 11", + ip_address: "192.168.1.11", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }) + + assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} = + errors_on(changeset) + end + + test "allows device creation when under limit", %{site: site} do + # Create 9 devices + for i <- 1..9 do + {:ok, _device} = + Devices.create_device(%{ + name: "Device #{i}", + ip_address: "192.168.1.#{i}", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + # 10th device should succeed + assert {:ok, _device} = + Devices.create_device(%{ + name: "Device 10", + ip_address: "192.168.1.10", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + test "bypasses limit with bypass_limits option", %{site: site} do + # Create 10 devices (at limit) + for i <- 1..10 do + {:ok, _device} = + Devices.create_device(%{ + name: "Device #{i}", + ip_address: "192.168.1.#{i}", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + # 11th device succeeds with bypass + assert {:ok, _device} = + Devices.create_device( + %{ + name: "Device 11", + ip_address: "192.168.1.11", + site_id: site.id, + monitoring_enabled: false, + snmp_enabled: false + }, + bypass_limits: true + ) + end + + test "counts devices across multiple sites in same organization", %{ + organization: organization, + site: site1 + } do + {:ok, site2} = + Towerops.Sites.create_site(%{ + name: "Site 2", + organization_id: organization.id + }) + + # Create 6 devices in site1 + for i <- 1..6 do + {:ok, _device} = + Devices.create_device(%{ + name: "Site1 Device #{i}", + ip_address: "192.168.1.#{i}", + site_id: site1.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + # Create 4 devices in site2 (total = 10) + for i <- 1..4 do + {:ok, _device} = + Devices.create_device(%{ + name: "Site2 Device #{i}", + ip_address: "192.168.2.#{i}", + site_id: site2.id, + monitoring_enabled: false, + snmp_enabled: false + }) + end + + # 11th device in either site should be blocked + assert {:error, changeset} = + Devices.create_device(%{ + name: "Site1 Device 7", + ip_address: "192.168.1.7", + site_id: site1.id, + monitoring_enabled: false, + snmp_enabled: false + }) + + assert %{base: ["You've reached your plan limit of 10 devices. Upgrade to add more."]} = + errors_on(changeset) + end + end end diff --git a/test/towerops/organizations/subscription_limits_test.exs b/test/towerops/organizations/subscription_limits_test.exs new file mode 100644 index 00000000..52d3ec78 --- /dev/null +++ b/test/towerops/organizations/subscription_limits_test.exs @@ -0,0 +1,213 @@ +defmodule Towerops.Organizations.SubscriptionLimitsTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Devices + alias Towerops.Organizations + alias Towerops.Organizations.SubscriptionLimits + alias Towerops.Sites + + describe "device_limit/1" do + test "returns 10 for free plan" do + assert SubscriptionLimits.device_limit("free") == 10 + end + + test "returns unlimited for any non-free plan" do + # Future: when other plans are added, they will return :unlimited + assert SubscriptionLimits.device_limit("paid") == :unlimited + assert SubscriptionLimits.device_limit("premium") == :unlimited + assert SubscriptionLimits.device_limit("enterprise") == :unlimited + end + end + + describe "check_device_limit/1" do + setup do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + %{organization: organization, site: site} + end + + test "returns ok when organization has no devices", %{organization: organization} do + assert {:ok, :within_limit} = SubscriptionLimits.check_device_limit(organization) + end + + test "returns ok when organization has devices under limit", %{ + organization: organization, + site: site + } do + # Create 5 devices + for i <- 1..5 do + create_device(site.id, "Device #{i}", "192.168.1.#{i}") + end + + organization = Repo.reload!(organization) + assert {:ok, :within_limit} = SubscriptionLimits.check_device_limit(organization) + end + + test "returns error when organization is at device limit", %{ + organization: organization, + site: site + } do + # Create 10 devices (the limit) + for i <- 1..10 do + create_device(site.id, "Device #{i}", "192.168.1.#{i}") + end + + organization = Repo.reload!(organization) + assert {:error, :at_limit, 10, 10} = SubscriptionLimits.check_device_limit(organization) + end + + test "returns error when organization is over device limit", %{ + organization: organization, + site: site + } do + # Create 12 devices (over the limit) + for i <- 1..12 do + create_device(site.id, "Device #{i}", "192.168.1.#{i}") + end + + organization = Repo.reload!(organization) + assert {:error, :at_limit, 12, 10} = SubscriptionLimits.check_device_limit(organization) + end + end + + describe "device_quota/1" do + setup do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + %{organization: organization, site: site} + end + + test "returns {0, 10} for free org with no devices", %{organization: organization} do + assert {0, 10} = SubscriptionLimits.device_quota(organization) + end + + test "returns {5, 10} for free org with 5 devices", %{organization: organization, site: site} do + for i <- 1..5 do + create_device(site.id, "Device #{i}", "192.168.1.#{i}") + end + + organization = Repo.reload!(organization) + assert {5, 10} = SubscriptionLimits.device_quota(organization) + end + end + + describe "can_create_free_organization?/1" do + test "returns true when user has no free organizations" do + user = user_fixture() + assert SubscriptionLimits.can_create_free_organization?(user.id) == true + end + + test "returns false when user owns one free organization" do + user = user_fixture() + {:ok, _org} = Organizations.create_organization(%{name: "Free Org"}, user.id) + + assert SubscriptionLimits.can_create_free_organization?(user.id) == false + end + + test "returns true when user is member but not owner of free organization" do + owner = user_fixture() + member = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Free Org"}, owner.id) + + # Add member to organization + {:ok, _membership} = + Organizations.create_membership(%{ + user_id: member.id, + organization_id: organization.id, + role: :member + }) + + # Member can still create their own free org + assert SubscriptionLimits.can_create_free_organization?(member.id) == true + end + end + + describe "count_user_owned_free_organizations/1" do + test "returns 0 when user has no organizations" do + user = user_fixture() + assert SubscriptionLimits.count_user_owned_free_organizations(user.id) == 0 + end + + test "returns 1 when user owns one free organization" do + user = user_fixture() + {:ok, _org} = Organizations.create_organization(%{name: "Free Org"}, user.id) + + assert SubscriptionLimits.count_user_owned_free_organizations(user.id) == 1 + end + + test "returns 0 when user is member but not owner" do + owner = user_fixture() + member = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Free Org"}, owner.id) + + {:ok, _membership} = + Organizations.create_membership(%{ + user_id: member.id, + organization_id: organization.id, + role: :admin + }) + + assert SubscriptionLimits.count_user_owned_free_organizations(member.id) == 0 + end + end + + describe "count_organization_devices/1" do + setup do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id}) + %{organization: organization, site: site} + end + + test "returns 0 for organization with no devices", %{organization: organization} do + assert SubscriptionLimits.count_organization_devices(organization.id) == 0 + end + + test "returns correct count for organization with devices", %{ + organization: organization, + site: site + } do + for i <- 1..7 do + create_device(site.id, "Device #{i}", "192.168.1.#{i}") + end + + assert SubscriptionLimits.count_organization_devices(organization.id) == 7 + end + + test "counts devices across multiple sites", %{organization: organization, site: site1} do + {:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + + for i <- 1..3 do + create_device(site1.id, "Site1 Device #{i}", "192.168.1.#{i}") + end + + for i <- 4..8 do + create_device(site2.id, "Site2 Device #{i}", "192.168.2.#{i}") + end + + assert SubscriptionLimits.count_organization_devices(organization.id) == 8 + end + end + + # Helper functions + + defp create_device(site_id, name, ip_address) do + {:ok, device} = + Devices.create_device( + %{ + name: name, + ip_address: ip_address, + site_id: site_id, + monitoring_enabled: false, + snmp_enabled: false + }, + bypass_limits: true + ) + + device + end +end diff --git a/test/towerops/organizations_test.exs b/test/towerops/organizations_test.exs index 0d6b2f17..296a7cc2 100644 --- a/test/towerops/organizations_test.exs +++ b/test/towerops/organizations_test.exs @@ -14,7 +14,8 @@ defmodule Towerops.OrganizationsTest do test "list_user_organizations/1 returns all organizations for a user", %{user: user} do {:ok, org1} = Organizations.create_organization(%{name: "Org 1"}, user.id) - {:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id) + # Use bypass_limits to create second org for testing + {:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id, bypass_limits: true) orgs = Organizations.list_user_organizations(user.id) assert length(orgs) == 2 @@ -593,4 +594,58 @@ defmodule Towerops.OrganizationsTest do assert count == 0 end end + + describe "organization creation with subscription limits" do + test "blocks creating second free organization" do + user = user_fixture() + {:ok, _org1} = Organizations.create_organization(%{name: "Free Org 1"}, user.id) + + assert {:error, changeset} = Organizations.create_organization(%{name: "Free Org 2"}, user.id) + + assert %{ + base: [ + "You already have a free organization. Upgrade your existing organization to create additional ones." + ] + } = + errors_on(changeset) + end + + test "allows creating first free organization" do + user = user_fixture() + + assert {:ok, org} = Organizations.create_organization(%{name: "Free Org"}, user.id) + assert org.subscription_plan == "free" + end + + test "bypasses limit with bypass_limits option" do + user = user_fixture() + {:ok, _org1} = Organizations.create_organization(%{name: "Free Org 1"}, user.id) + + # Second free org succeeds with bypass + assert {:ok, _org2} = + Organizations.create_organization( + %{name: "Free Org 2"}, + user.id, + bypass_limits: true + ) + end + + test "invited members can create their own free organization" do + owner = user_fixture() + member = user_fixture() + + {:ok, organization} = Organizations.create_organization(%{name: "Owner's Org"}, owner.id) + + # Add member to organization + {:ok, _membership} = + Organizations.create_membership(%{ + user_id: member.id, + organization_id: organization.id, + role: :member + }) + + # Member can still create their own free org + assert {:ok, _org} = Organizations.create_organization(%{name: "Member's Org"}, member.id) + end + end end diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs index 2802d698..3c05515c 100644 --- a/test/towerops_web/live/agent_live_test.exs +++ b/test/towerops_web/live/agent_live_test.exs @@ -233,7 +233,7 @@ defmodule ToweropsWeb.AgentLiveTest do test "verifies agent belongs to organization", %{conn: conn, organization: _organization, user: user} do # Create another organization - {:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id) + {:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true) {:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent") # Try to edit agent from other organization - capture_log suppresses expected Router exception log diff --git a/test/towerops_web/live/org_live_test.exs b/test/towerops_web/live/org_live_test.exs index 960dde80..75047c73 100644 --- a/test/towerops_web/live/org_live_test.exs +++ b/test/towerops_web/live/org_live_test.exs @@ -90,9 +90,14 @@ defmodule ToweropsWeb.OrgLiveTest do end test "allows duplicate organization names with different slugs", %{conn: conn, user: user} do - {:ok, _organization} = - Towerops.Organizations.create_organization(%{name: "Existing Org"}, user.id) + # Make user a superuser to bypass free org limits + user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true)) + {:ok, _organization} = + Towerops.Organizations.create_organization(%{name: "Existing Org"}, user.id, bypass_limits: true) + + # Need to reconnect with updated user + conn = log_in_user(conn, user) {:ok, view, _html} = live(conn, ~p"/orgs/new") view diff --git a/test/towerops_web/user_auth_test.exs b/test/towerops_web/user_auth_test.exs index 440f5997..80f140f4 100644 --- a/test/towerops_web/user_auth_test.exs +++ b/test/towerops_web/user_auth_test.exs @@ -1323,7 +1323,7 @@ defmodule ToweropsWeb.UserAuthTest do property "redirect paths are always valid for authenticated users", %{user: user} do check all(has_org <- boolean(), max_runs: 10) do if has_org do - {:ok, _org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, _org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id, bypass_limits: true) end # Reload user to get fresh org data