add device/org limit

This commit is contained in:
Graham McIntire 2026-01-27 14:14:25 -06:00
parent d1cfbaa87c
commit 5fb92dd961
No known key found for this signature in database
20 changed files with 920 additions and 86 deletions

View file

@ -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

View file

@ -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))

View file

@ -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])

View file

@ -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

View file

@ -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", %{})}

View file

@ -19,14 +19,79 @@
</.link>
</div>
<.header>
{@page_title}
<:subtitle>
{if @live_action == :new,
do: "Add new device to monitor",
else: "Update device details"}
</:subtitle>
</.header>
<div class="flex items-center justify-between mb-8">
<div>
<.header>
{@page_title}
<:subtitle>
{if @live_action == :new,
do: "Add new device to monitor",
else: "Update device details"}
</:subtitle>
</.header>
</div>
<%= 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 %>
<div class={"rounded-lg px-3 py-2 text-sm font-medium border #{badge_class}"}>
<%= 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 %>
</div>
<% end %>
</div>
<!-- Warning banner when approaching limit -->
<%= 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 %>
<div class="mb-6 rounded-lg bg-orange-50 dark:bg-orange-900/20 p-4 border border-orange-200 dark:border-orange-800">
<div class="flex">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" />
<div class="ml-3">
<h3 class="text-sm font-medium text-orange-800 dark:text-orange-400">
Approaching device limit
</h3>
<p class="mt-1 text-sm text-orange-700 dark:text-orange-300">
You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining.
</p>
</div>
</div>
</div>
<% end %>
<% end %>
<div class="max-w-2xl">
<.form for={@form} id="device-form" phx-change="validate" phx-submit="save">

View file

@ -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

View file

@ -5,51 +5,96 @@
active_page="devices"
timezone={@timezone}
>
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network devices</:subtitle>
<: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>
<.button
type="button"
phx-click="toggle_reorder_mode"
variant="primary"
>
<.icon name="hero-check" class="h-4 w-4" /> Done
</.button>
<% else %>
<.button
type="button"
phx-click="toggle_reorder_mode"
>
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
</.button>
<.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
</.button>
<% end %>
<div class="flex items-start justify-between mb-8">
<div>
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network devices</:subtitle>
</.header>
</div>
<% 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 %>
<div class={"rounded-lg px-3 py-2 text-sm font-medium border #{badge_class}"}>
<%= 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
</.button>
</:actions>
</.header>
</div>
</div>
<div class="flex gap-3 mb-6">
<%= 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>
<.button
type="button"
phx-click="toggle_reorder_mode"
variant="primary"
>
<.icon name="hero-check" class="h-4 w-4" /> Done
</.button>
<% else %>
<.button
type="button"
phx-click="toggle_reorder_mode"
>
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
</.button>
<.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
</.button>
<% end %>
<% end %>
<.button
:if={@has_sites}
navigate={~p"/devices/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-white/10 mb-6">

View file

@ -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

View file

@ -9,6 +9,25 @@
<:subtitle>Create a new organization to manage your sites and devices</:subtitle>
</.header>
<%= if not @can_create_free do %>
<div class="max-w-2xl mb-6 rounded-lg bg-orange-50 dark:bg-orange-900/20 p-4 border border-orange-200 dark:border-orange-800">
<div class="flex">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-orange-400" />
<div class="ml-3">
<h3 class="text-sm font-medium text-orange-800 dark:text-orange-400">
Free organization limit reached
</h3>
<p class="mt-1 text-sm text-orange-700 dark:text-orange-300">
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.
</p>
</div>
</div>
</div>
<% end %>
<div class="max-w-2xl">
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Organization Name" required />

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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