feat: per-organization billing override admin UI

Allow superadmins to override free device limits and per-device pricing
on a per-organization basis. Overrides cascade through subscription
limits, billing calculations, and user-facing settings display.

- Add custom_free_device_limit and custom_price_per_device to organizations
- Add billing_override_changeset with validation
- Update SubscriptionLimits.effective_device_limit to respect overrides
- Update Billing to use effective free count and price per device
- Add Admin.update_billing_overrides with audit logging
- Add override editing UI to /admin/organizations
- Update org settings page to show effective limits/pricing
This commit is contained in:
Graham McIntire 2026-03-06 13:02:05 -06:00
parent 1d98cda79d
commit 9aec87636b
No known key found for this signature in database
14 changed files with 779 additions and 33 deletions

View file

@ -124,7 +124,15 @@ defmodule Towerops.Admin do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
device_count_query =
from(d in "devices",
where: d.organization_id == parent_as(:org).id,
select: count(d.id)
)
Organization
|> from(as: :org)
|> select_merge([o], %{device_count: subquery(device_count_query)})
|> order_by([o], desc: o.inserted_at)
|> limit(^limit)
|> offset(^offset)
@ -173,6 +181,37 @@ defmodule Towerops.Admin do
end)
end
@doc """
Updates billing overrides for an organization with audit logging.
Only superusers should call this function. Creates an audit log entry
recording the change.
"""
def update_billing_overrides(org_id, attrs, superuser_id, ip_address) do
org = Repo.get!(Organization, org_id)
changeset = Organization.billing_override_changeset(org, attrs)
if changeset.valid? do
Repo.transaction(fn ->
create_audit_log(%{
action: "org_billing_override_updated",
superuser_id: superuser_id,
metadata: %{
organization_id: org.id,
organization_name: org.name,
changes: changeset.changes
},
ip_address: ip_address
})
Repo.update!(changeset)
end)
else
{:error, changeset}
end
end
## Audit Logging
@doc """

View file

@ -62,7 +62,9 @@ defmodule Towerops.Admin.AuditLog do
# Security events
"failed_access_attempt",
"privilege_escalation",
"sudo_mode_granted"
"sudo_mode_granted",
# Billing overrides
"org_billing_override_updated"
])
end
end

View file

@ -13,6 +13,9 @@ defmodule Towerops.Billing do
require Logger
@default_free_devices 10
@default_price_per_device Decimal.new("1.00")
# Public API
@doc """
@ -70,7 +73,24 @@ defmodule Towerops.Billing do
"""
def billable_device_count(organization) do
total = SubscriptionLimits.count_organization_devices(organization.id)
max(0, total - 10)
free = effective_free_device_count(organization)
max(0, total - free)
end
@doc """
Returns the effective number of free devices for an organization.
Uses custom override if set, otherwise the default (10).
"""
def effective_free_device_count(%Organization{} = org) do
org.custom_free_device_limit || @default_free_devices
end
@doc """
Returns the effective price per device for an organization.
Uses custom override if set, otherwise the default ($1.00).
"""
def effective_price_per_device(%Organization{} = org) do
org.custom_price_per_device || @default_price_per_device
end
@doc """
@ -78,18 +98,22 @@ defmodule Towerops.Billing do
## Examples
iex> estimated_monthly_cost(organization)
{:ok, %{devices: 25, billable: 15, cost_usd: #Decimal<15.00>}}
{:ok, %{devices: 25, billable: 15, cost_usd: #Decimal<15.00>, free_included: 10, price_per_device: #Decimal<1.00>}}
"""
def estimated_monthly_cost(organization) do
total = SubscriptionLimits.count_organization_devices(organization.id)
billable = max(0, total - 10)
cost = Decimal.new(billable)
free = effective_free_device_count(organization)
price = effective_price_per_device(organization)
billable = max(0, total - free)
cost = Decimal.mult(Decimal.new(billable), price)
{:ok,
%{
devices: total,
billable: billable,
cost_usd: cost
cost_usd: cost,
free_included: free,
price_per_device: price
}}
end

View file

@ -49,6 +49,10 @@ defmodule Towerops.Organizations.Organization do
field :mikrotik_use_ssl, :boolean, default: true
field :mikrotik_enabled, :boolean, default: false
# Billing overrides (nil = use defaults)
field :custom_free_device_limit, :integer
field :custom_price_per_device, :decimal
# Billing fields
field :stripe_customer_id, :string
field :stripe_subscription_id, :string
@ -59,6 +63,9 @@ defmodule Towerops.Organizations.Organization do
field :last_billing_sync_at, :utc_datetime
field :last_synced_device_count, :integer
# Virtual fields (populated by queries)
field :device_count, :integer, virtual: true
belongs_to :default_agent_token, AgentToken
has_many :memberships, Membership
@ -90,6 +97,9 @@ defmodule Towerops.Organizations.Organization do
mikrotik_ssh_port: integer() | nil,
mikrotik_use_ssl: boolean() | nil,
mikrotik_enabled: boolean() | nil,
custom_free_device_limit: integer() | nil,
custom_price_per_device: Decimal.t() | nil,
device_count: integer() | nil,
stripe_customer_id: String.t() | nil,
stripe_subscription_id: String.t() | nil,
subscription_status: String.t() | nil,
@ -153,6 +163,21 @@ defmodule Towerops.Organizations.Organization do
|> foreign_key_constraint(:default_agent_token_id)
end
@doc """
Changeset for admin-only billing override fields.
Only casts `custom_free_device_limit` and `custom_price_per_device`.
"""
def billing_override_changeset(organization, attrs) do
organization
|> cast(attrs, [:custom_free_device_limit, :custom_price_per_device])
|> validate_number(:custom_free_device_limit, greater_than: 0, less_than: 10_000)
|> validate_number(:custom_price_per_device,
greater_than_or_equal_to: 0,
less_than: 1000
)
end
defp validate_snmpv3_fields(changeset) do
snmp_version = get_field(changeset, :snmp_version)

View file

@ -30,6 +30,21 @@ defmodule Towerops.Organizations.SubscriptionLimits do
def device_limit("free"), do: @free_device_limit
def device_limit(_other), do: :unlimited
@doc """
Returns the effective device limit for an organization,
taking into account any custom billing override.
For paid plans, always returns `:unlimited` regardless of overrides.
For free plans, returns the custom limit if set, otherwise the default (10).
"""
@spec effective_device_limit(Organization.t()) :: integer() | :unlimited
def effective_device_limit(%Organization{} = org) do
case device_limit(org.subscription_plan) do
:unlimited -> :unlimited
default -> org.custom_free_device_limit || default
end
end
@doc """
Checks if an organization is within its device quota.
@ -45,7 +60,7 @@ defmodule Towerops.Organizations.SubscriptionLimits do
{:error, :at_limit, 10, 10}
"""
def check_device_limit(%Organization{} = organization) do
case device_limit(organization.subscription_plan) do
case effective_device_limit(organization) do
:unlimited ->
{:ok, :within_limit}
@ -75,7 +90,7 @@ defmodule Towerops.Organizations.SubscriptionLimits do
"""
def device_quota(%Organization{} = organization) do
current = count_organization_devices(organization.id)
limit = device_limit(organization.subscription_plan)
limit = effective_device_limit(organization)
{current, limit}
end

View file

@ -5,22 +5,117 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
use ToweropsWeb, :live_view
alias Towerops.Admin
alias Towerops.Organizations.Organization
@impl true
def mount(_params, _session, socket) do
orgs = Admin.list_all_organizations()
ip =
case get_connect_info(socket, :peer_data) do
%{address: address} -> to_string(:inet_parse.ntoa(address))
_ -> "unknown"
end
{:ok,
socket
|> assign(:page_title, t("All Organizations"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:organizations, orgs)}
|> assign(:organizations, orgs)
|> assign(:client_ip, ip)
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
end
@impl true
def handle_params(params, _url, socket) do
case params["edit"] do
nil ->
{:noreply,
socket
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
org_id ->
org = Enum.find(socket.assigns.organizations, &(&1.id == org_id))
if org do
changeset = Organization.billing_override_changeset(org, %{})
{:noreply,
socket
|> assign(:editing_org, org)
|> assign(:override_form, to_form(changeset))}
else
{:noreply,
socket
|> assign(:editing_org, nil)
|> assign(:override_form, nil)}
end
end
end
@impl true
def handle_event("edit_overrides", %{"id" => org_id}, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/organizations?edit=#{org_id}")}
end
def handle_event("validate_overrides", %{"organization" => params}, socket) do
changeset =
socket.assigns.editing_org
|> Organization.billing_override_changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :override_form, to_form(changeset))}
end
def handle_event("save_overrides", %{"organization" => params}, socket) do
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
org = socket.assigns.editing_org
case Admin.update_billing_overrides(org.id, params, superuser.id, socket.assigns.client_ip) do
{:ok, _updated_org} ->
orgs = Admin.list_all_organizations()
{:noreply,
socket
|> put_flash(:info, t_admin("Billing overrides updated"))
|> assign(:organizations, orgs)
|> push_patch(to: ~p"/admin/organizations")}
{:error, changeset} ->
{:noreply, assign(socket, :override_form, to_form(changeset))}
end
end
def handle_event("clear_overrides", _params, socket) do
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
org = socket.assigns.editing_org
attrs = %{custom_free_device_limit: nil, custom_price_per_device: nil}
case Admin.update_billing_overrides(org.id, attrs, superuser.id, socket.assigns.client_ip) do
{:ok, _updated_org} ->
orgs = Admin.list_all_organizations()
{:noreply,
socket
|> put_flash(:info, t_admin("Billing overrides cleared"))
|> assign(:organizations, orgs)
|> push_patch(to: ~p"/admin/organizations")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_admin("Failed to clear overrides"))}
end
end
def handle_event("cancel_overrides", _params, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/organizations")}
end
def handle_event("delete_org", %{"id" => org_id}, socket) do
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
ip = get_connect_info_ip(socket)
ip = socket.assigns.client_ip
case Admin.delete_organization(org_id, superuser.id, ip) do
{:ok, _} ->
@ -33,11 +128,4 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
{:noreply, put_flash(socket, :error, t_admin("Failed to delete organization"))}
end
end
defp get_connect_info_ip(socket) do
case get_connect_info(socket, :peer_data) do
%{address: address} -> to_string(:inet_parse.ntoa(address))
_ -> "unknown"
end
end
end

View file

@ -2,32 +2,133 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("All Organizations")}</h1>
<p class="text-gray-600 dark:text-gray-400">Manage organizations</p>
<p class="text-gray-600 dark:text-gray-400">{t_admin("Manage organizations")}</p>
</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>
<:col :let={org} label={t("Slug")}>{org.slug}</:col>
<:col :let={org} label={t("Plan")}>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
if(org.subscription_plan == "paid",
do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
else: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
)
]}>
{org.subscription_plan}
</span>
</:col>
<:col :let={org} label={t("Devices")}>
{org.device_count}
</:col>
<:col :let={org} label={t("Overrides")}>
<%= if org.custom_free_device_limit || org.custom_price_per_device do %>
<div class="flex flex-wrap gap-1">
<%= if org.custom_free_device_limit do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{org.custom_free_device_limit} {t("free")}
</span>
<% end %>
<%= if org.custom_price_per_device do %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
${org.custom_price_per_device}/{t("device")}
</span>
<% end %>
</div>
<% else %>
<span class="text-gray-400 dark:text-gray-500 text-xs">{t("Default")}</span>
<% end %>
</:col>
<:col :let={org} label={t("Members")}>{length(org.memberships)}</:col>
<:col :let={org} label={t("Created")}>
{ToweropsWeb.TimeHelpers.format_date(org.inserted_at, @timezone)}
</:col>
<:col :let={org} label="">
<button
phx-click="delete_org"
phx-value-id={org.id}
data-confirm={
t(
"Are you sure? This will delete all sites, device, and data for this organization."
)
}
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
{t("Delete")}
</button>
<div class="flex items-center gap-3">
<button
id={"edit-overrides-#{org.id}"}
phx-click="edit_overrides"
phx-value-id={org.id}
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t_admin("Edit")}
</button>
<button
phx-click="delete_org"
phx-value-id={org.id}
data-confirm={
t(
"Are you sure? This will delete all sites, device, and data for this organization."
)
}
class="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
{t("Delete")}
</button>
</div>
</:col>
</.table>
</div>
<%!-- Billing Override Edit Panel --%>
<%= if @editing_org do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{t_admin("Billing Overrides")} — {@editing_org.name}
</h2>
<button
id="cancel-overrides-btn"
phx-click="cancel_overrides"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{t("Cancel")}
</button>
</div>
<.form
for={@override_form}
id="billing-override-form"
phx-change="validate_overrides"
phx-submit="save_overrides"
>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.input
field={@override_form[:custom_free_device_limit]}
type="number"
label={t_admin("Custom Free Device Limit")}
placeholder="10 (default)"
min="1"
max="9999"
/>
<.input
field={@override_form[:custom_price_per_device]}
type="number"
label={t_admin("Custom Price Per Device")}
placeholder="1.00 (default)"
step="0.01"
min="0"
max="999.99"
/>
</div>
<div class="mt-4 flex items-center gap-3">
<.button type="submit" phx-disable-with={t("Saving...")}>
{t("Save")}
</.button>
<button
id="clear-overrides-btn"
type="button"
phx-click="clear_overrides"
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
{t_admin("Reset to Defaults")}
</button>
</div>
</.form>
</div>
<% end %>
</div>
</Layouts.admin>

View file

@ -2113,7 +2113,7 @@
${@estimated_cost.cost_usd}
</dd>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{@estimated_cost.billable} {t("billable devices")} × $1.00
{@estimated_cost.billable} {t("billable devices")} × ${@estimated_cost.price_per_device}
</p>
</div>
@ -2146,10 +2146,14 @@
<% else %>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded p-3">
<p class="text-sm text-blue-800 dark:text-blue-300">
{t("Free Plan - First 10 devices included")}
{t("Free Plan - First %{count} devices included",
count: @estimated_cost.free_included
)}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{t("Upgrade to monitor unlimited devices at $1/device/month")}
{t("Upgrade to monitor unlimited devices at $%{price}/device/month",
price: @estimated_cost.price_per_device
)}
</p>
</div>
<% end %>

View file

@ -0,0 +1,10 @@
defmodule Towerops.Repo.Migrations.AddBillingOverridesToOrganizations do
use Ecto.Migration
def change do
alter table(:organizations) do
add :custom_free_device_limit, :integer
add :custom_price_per_device, :decimal, precision: 10, scale: 2
end
end
end

View file

@ -658,4 +658,126 @@ defmodule Towerops.AdminTest do
assert hd(result).target_user_id == nil
end
end
describe "update_billing_overrides/4" do
setup do
superuser = user_fixture(%{superuser: true})
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Billing Org"}, user.id)
%{superuser: superuser, organization: org}
end
test "updates custom_free_device_limit", %{superuser: superuser, organization: org} do
assert {:ok, updated_org} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: 50},
superuser.id,
"192.168.1.1"
)
assert updated_org.custom_free_device_limit == 50
end
test "updates custom_price_per_device", %{superuser: superuser, organization: org} do
assert {:ok, updated_org} =
Admin.update_billing_overrides(
org.id,
%{custom_price_per_device: "0.50"},
superuser.id,
"192.168.1.1"
)
assert Decimal.equal?(updated_org.custom_price_per_device, Decimal.new("0.50"))
end
test "updates both fields at once", %{superuser: superuser, organization: org} do
assert {:ok, updated_org} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: 25, custom_price_per_device: "0.75"},
superuser.id,
"192.168.1.1"
)
assert updated_org.custom_free_device_limit == 25
assert Decimal.equal?(updated_org.custom_price_per_device, Decimal.new("0.75"))
end
test "clears overrides with nil values", %{superuser: superuser, organization: org} do
# First set overrides
{:ok, _} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: 50},
superuser.id,
"192.168.1.1"
)
# Then clear them
{:ok, updated_org} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: nil},
superuser.id,
"192.168.1.1"
)
assert is_nil(updated_org.custom_free_device_limit)
end
test "creates audit log entry", %{superuser: superuser, organization: org} do
{:ok, _} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: 50, custom_price_per_device: "0.50"},
superuser.id,
"192.168.1.1"
)
audit_logs = Admin.list_audit_logs()
log = Enum.find(audit_logs, &(&1.action == "org_billing_override_updated"))
assert log
assert log.superuser_id == superuser.id
assert log.ip_address.address == "192.168.1.1"
assert log.metadata["organization_id"] == org.id
assert log.metadata["organization_name"] == org.name
assert log.metadata["changes"]["custom_free_device_limit"] == 50
end
test "returns error for invalid overrides", %{superuser: superuser, organization: org} do
assert {:error, _changeset} =
Admin.update_billing_overrides(
org.id,
%{custom_free_device_limit: -5},
superuser.id,
"192.168.1.1"
)
end
test "raises for non-existent organization", %{superuser: superuser} do
assert_raise Ecto.NoResultsError, fn ->
Admin.update_billing_overrides(
Ecto.UUID.generate(),
%{custom_free_device_limit: 50},
superuser.id,
"192.168.1.1"
)
end
end
end
describe "list_all_organizations/1 with device_count" do
test "includes device_count virtual field" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Count Org"}, user.id)
result = Admin.list_all_organizations()
found_org = Enum.find(result, &(&1.id == org.id))
assert found_org.device_count == 0
end
end
end

View file

@ -9,6 +9,7 @@ defmodule Towerops.BillingTest do
alias Towerops.Billing
alias Towerops.Billing.StripeClient
alias Towerops.Organizations
alias Towerops.Organizations.Organization
setup do
user = user_fixture()
@ -183,6 +184,22 @@ defmodule Towerops.BillingTest do
# 25 - 10 free = 15 billable
assert Billing.billable_device_count(organization) == 15
end
test "uses custom free device count when set", %{organization: organization} do
# Set custom free device count to 20
organization
|> Organization.billing_override_changeset(%{custom_free_device_limit: 20})
|> Towerops.Repo.update!()
organization = Towerops.Repo.get!(Organization, organization.id)
# Create 25 devices, 20 free = 5 billable
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
assert Billing.billable_device_count(organization) == 5
end
end
describe "estimated_monthly_cost/1" do
@ -209,6 +226,50 @@ defmodule Towerops.BillingTest do
assert Decimal.equal?(cost, Decimal.new(0))
end
test "uses custom pricing and free device count", %{organization: organization} do
# Set custom: 5 free devices, $0.50/device
organization
|> Organization.billing_override_changeset(%{
custom_free_device_limit: 5,
custom_price_per_device: "0.50"
})
|> Towerops.Repo.update!()
organization = Towerops.Repo.get!(Organization, organization.id)
# Create 15 devices: 5 free, 10 billable at $0.50 = $5.00
for _ <- 1..15 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok,
%{
devices: 15,
billable: 10,
cost_usd: cost,
free_included: 5,
price_per_device: price
}} = Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(cost, Decimal.new("5.00"))
assert Decimal.equal?(price, Decimal.new("0.50"))
end
test "returns effective values in estimated cost map", %{organization: organization} do
# Default org (no overrides): 10 free, $1.00/device
for _ <- 1..15 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok,
%{
free_included: 10,
price_per_device: price
}} = Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(price, Decimal.new("1.00"))
end
end
describe "sync_usage_to_stripe/1" do

View file

@ -372,4 +372,103 @@ defmodule Towerops.Organizations.OrganizationTest do
refute Organization.has_valid_payment_method?(org)
end
end
describe "billing_override_changeset/2" do
test "valid with custom_free_device_limit" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_free_device_limit: 50})
assert changeset.valid?
assert get_change(changeset, :custom_free_device_limit) == 50
end
test "valid with custom_price_per_device" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_price_per_device: "0.50"})
assert changeset.valid?
assert get_change(changeset, :custom_price_per_device) == Decimal.new("0.50")
end
test "valid with both fields" do
org = %Organization{name: "Test", slug: "test123"}
changeset =
Organization.billing_override_changeset(org, %{
custom_free_device_limit: 25,
custom_price_per_device: "0.75"
})
assert changeset.valid?
end
test "valid with nil values to clear overrides" do
org = %Organization{name: "Test", slug: "test123", custom_free_device_limit: 50}
changeset = Organization.billing_override_changeset(org, %{custom_free_device_limit: nil})
assert changeset.valid?
end
test "rejects custom_free_device_limit of 0" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_free_device_limit: 0})
refute changeset.valid?
assert %{custom_free_device_limit: _} = errors_on(changeset)
end
test "rejects negative custom_free_device_limit" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_free_device_limit: -5})
refute changeset.valid?
assert %{custom_free_device_limit: _} = errors_on(changeset)
end
test "rejects custom_free_device_limit >= 10000" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_free_device_limit: 10_000})
refute changeset.valid?
assert %{custom_free_device_limit: _} = errors_on(changeset)
end
test "rejects negative custom_price_per_device" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_price_per_device: "-1.00"})
refute changeset.valid?
assert %{custom_price_per_device: _} = errors_on(changeset)
end
test "accepts zero custom_price_per_device" do
org = %Organization{name: "Test", slug: "test123"}
changeset = Organization.billing_override_changeset(org, %{custom_price_per_device: "0.00"})
assert changeset.valid?
end
test "rejects custom_price_per_device >= 1000" do
org = %Organization{name: "Test", slug: "test123"}
changeset =
Organization.billing_override_changeset(org, %{custom_price_per_device: "1000.00"})
refute changeset.valid?
assert %{custom_price_per_device: _} = errors_on(changeset)
end
test "does not affect other organization fields" do
org = %Organization{name: "Test", slug: "test123"}
changeset =
Organization.billing_override_changeset(org, %{
custom_free_device_limit: 50,
name: "Hacked"
})
assert changeset.valid?
refute get_change(changeset, :name)
end
end
end

View file

@ -5,6 +5,7 @@ defmodule Towerops.Organizations.SubscriptionLimitsTest do
alias Towerops.Devices
alias Towerops.Organizations
alias Towerops.Organizations.Organization
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Sites
@ -21,6 +22,28 @@ defmodule Towerops.Organizations.SubscriptionLimitsTest do
end
end
describe "effective_device_limit/1" do
test "returns default limit for free org without override" do
org = %Organization{subscription_plan: "free", custom_free_device_limit: nil}
assert SubscriptionLimits.effective_device_limit(org) == 10
end
test "returns custom limit for free org with override" do
org = %Organization{subscription_plan: "free", custom_free_device_limit: 50}
assert SubscriptionLimits.effective_device_limit(org) == 50
end
test "returns unlimited for paid org without override" do
org = %Organization{subscription_plan: "paid", custom_free_device_limit: nil}
assert SubscriptionLimits.effective_device_limit(org) == :unlimited
end
test "returns unlimited for paid org even with override" do
org = %Organization{subscription_plan: "paid", custom_free_device_limit: 50}
assert SubscriptionLimits.effective_device_limit(org) == :unlimited
end
end
describe "check_device_limit/1" do
setup do
user = user_fixture()
@ -71,6 +94,38 @@ defmodule Towerops.Organizations.SubscriptionLimitsTest do
organization = Repo.reload!(organization)
assert {:error, :at_limit, 12, 10} = SubscriptionLimits.check_device_limit(organization)
end
test "uses custom limit when set", %{organization: organization, site: site} do
# Set custom limit of 5
organization
|> Organization.billing_override_changeset(%{custom_free_device_limit: 5})
|> Repo.update!()
organization = Repo.get!(Organization, organization.id)
# Create 5 devices (at custom limit)
for i <- 1..5 do
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
end
assert {:error, :at_limit, 5, 5} = SubscriptionLimits.check_device_limit(organization)
end
test "allows more devices with higher custom limit", %{organization: organization, site: site} do
# Set custom limit of 20
organization
|> Organization.billing_override_changeset(%{custom_free_device_limit: 20})
|> Repo.update!()
organization = Repo.get!(Organization, organization.id)
# Create 15 devices (under custom limit of 20)
for i <- 1..15 do
create_device(site.id, "Device #{i}", "192.168.1.#{i}")
end
assert {:ok, :within_limit} = SubscriptionLimits.check_device_limit(organization)
end
end
describe "device_quota/1" do
@ -93,6 +148,15 @@ defmodule Towerops.Organizations.SubscriptionLimitsTest do
organization = Repo.reload!(organization)
assert {5, 10} = SubscriptionLimits.device_quota(organization)
end
test "uses custom limit in quota", %{organization: organization} do
organization
|> Organization.billing_override_changeset(%{custom_free_device_limit: 25})
|> Repo.update!()
organization = Repo.get!(Organization, organization.id)
assert {0, 25} = SubscriptionLimits.device_quota(organization)
end
end
describe "can_create_free_organization?/1" do

View file

@ -3,6 +3,8 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
import Phoenix.LiveViewTest
alias Towerops.Organizations.Organization
setup do
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user = user |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!()
@ -73,4 +75,94 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
assert has_element?(view, "button[phx-click='delete_org'][phx-value-id='#{organization.id}']")
end
end
describe "billing overrides" do
test "shows edit overrides button", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
assert has_element?(view, "#edit-overrides-#{organization.id}")
end
test "opens edit panel via URL param", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit=#{organization.id}")
assert has_element?(view, "#billing-override-form")
end
test "validates override form", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit=#{organization.id}")
html =
view
|> form("#billing-override-form", %{
"organization" => %{"custom_free_device_limit" => "-5"}
})
|> render_change()
assert html =~ "must be greater than 0"
end
test "saves billing overrides", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit=#{organization.id}")
view
|> form("#billing-override-form", %{
"organization" => %{
"custom_free_device_limit" => "50",
"custom_price_per_device" => "0.50"
}
})
|> render_submit()
# Should show success flash and the override badge
html = render(view)
assert html =~ "Billing overrides updated"
end
test "clears overrides with reset", %{conn: conn, organization: organization} do
# First set overrides
org = Towerops.Repo.get!(Organization, organization.id)
org
|> Organization.billing_override_changeset(%{
custom_free_device_limit: 50
})
|> Towerops.Repo.update!()
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit=#{organization.id}")
view
|> element("#clear-overrides-btn")
|> render_click()
html = render(view)
assert html =~ "Billing overrides cleared"
end
test "shows override badges for orgs with overrides", %{conn: conn, organization: organization} do
org = Towerops.Repo.get!(Organization, organization.id)
org
|> Organization.billing_override_changeset(%{
custom_free_device_limit: 50
})
|> Towerops.Repo.update!()
{:ok, _view, html} = live(conn, ~p"/admin/organizations")
assert html =~ "50 free"
end
test "cancel closes the edit panel", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit=#{organization.id}")
assert has_element?(view, "#billing-override-form")
view
|> element("#cancel-overrides-btn")
|> render_click()
refute has_element?(view, "#billing-override-form")
end
end
end