diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex
index e1e61703..0a582750 100644
--- a/lib/towerops/admin.ex
+++ b/lib/towerops/admin.ex
@@ -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 """
diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex
index c6cfb0a5..ce640d0c 100644
--- a/lib/towerops/admin/audit_log.ex
+++ b/lib/towerops/admin/audit_log.ex
@@ -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
diff --git a/lib/towerops/billing.ex b/lib/towerops/billing.ex
index 1be29e49..f9bc2fb7 100644
--- a/lib/towerops/billing.ex
+++ b/lib/towerops/billing.ex
@@ -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
diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex
index 633fb1e2..9d261e44 100644
--- a/lib/towerops/organizations/organization.ex
+++ b/lib/towerops/organizations/organization.ex
@@ -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)
diff --git a/lib/towerops/organizations/subscription_limits.ex b/lib/towerops/organizations/subscription_limits.ex
index 950bc75f..84dfff7a 100644
--- a/lib/towerops/organizations/subscription_limits.ex
+++ b/lib/towerops/organizations/subscription_limits.ex
@@ -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
diff --git a/lib/towerops_web/live/admin/org_live/index.ex b/lib/towerops_web/live/admin/org_live/index.ex
index 6d4326f7..f5dcfc8f 100644
--- a/lib/towerops_web/live/admin/org_live/index.ex
+++ b/lib/towerops_web/live/admin/org_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/admin/org_live/index.html.heex b/lib/towerops_web/live/admin/org_live/index.html.heex
index 471e67f3..848e952e 100644
--- a/lib/towerops_web/live/admin/org_live/index.html.heex
+++ b/lib/towerops_web/live/admin/org_live/index.html.heex
@@ -2,32 +2,133 @@
{t("All Organizations")}
-
Manage organizations
+
{t_admin("Manage organizations")}
<.table id="organizations" rows={@organizations}>
<:col :let={org} label={t("Name")}>{org.name}
<:col :let={org} label={t("Slug")}>{org.slug}
+ <:col :let={org} label={t("Plan")}>
+
+ {org.subscription_plan}
+
+
+ <:col :let={org} label={t("Devices")}>
+ {org.device_count}
+
+ <:col :let={org} label={t("Overrides")}>
+ <%= if org.custom_free_device_limit || org.custom_price_per_device do %>
+
+ <%= if org.custom_free_device_limit do %>
+
+ {org.custom_free_device_limit} {t("free")}
+
+ <% end %>
+ <%= if org.custom_price_per_device do %>
+
+ ${org.custom_price_per_device}/{t("device")}
+
+ <% end %>
+
+ <% else %>
+
{t("Default")}
+ <% end %>
+
<:col :let={org} label={t("Members")}>{length(org.memberships)}
<:col :let={org} label={t("Created")}>
{ToweropsWeb.TimeHelpers.format_date(org.inserted_at, @timezone)}
<:col :let={org} label="">
-
- {t("Delete")}
-
+
+
+ {t_admin("Edit")}
+
+
+ {t("Delete")}
+
+
+
+ <%!-- Billing Override Edit Panel --%>
+ <%= if @editing_org do %>
+
+
+
+ {t_admin("Billing Overrides")} — {@editing_org.name}
+
+
+ {t("Cancel")}
+
+
+
+ <.form
+ for={@override_form}
+ id="billing-override-form"
+ phx-change="validate_overrides"
+ phx-submit="save_overrides"
+ >
+
+ <.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"
+ />
+
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}>
+ {t("Save")}
+
+
+ {t_admin("Reset to Defaults")}
+
+
+
+
+ <% end %>
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex
index 8ff7d5df..abf5451f 100644
--- a/lib/towerops_web/live/org/settings_live.html.heex
+++ b/lib/towerops_web/live/org/settings_live.html.heex
@@ -2113,7 +2113,7 @@
${@estimated_cost.cost_usd}
- {@estimated_cost.billable} {t("billable devices")} × $1.00
+ {@estimated_cost.billable} {t("billable devices")} × ${@estimated_cost.price_per_device}
@@ -2146,10 +2146,14 @@
<% else %>
- {t("Free Plan - First 10 devices included")}
+ {t("Free Plan - First %{count} devices included",
+ count: @estimated_cost.free_included
+ )}
- {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
+ )}
<% end %>
diff --git a/priv/repo/migrations/20260306184112_add_billing_overrides_to_organizations.exs b/priv/repo/migrations/20260306184112_add_billing_overrides_to_organizations.exs
new file mode 100644
index 00000000..61d8fb3b
--- /dev/null
+++ b/priv/repo/migrations/20260306184112_add_billing_overrides_to_organizations.exs
@@ -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
diff --git a/test/towerops/admin_test.exs b/test/towerops/admin_test.exs
index b735a11e..9734878a 100644
--- a/test/towerops/admin_test.exs
+++ b/test/towerops/admin_test.exs
@@ -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
diff --git a/test/towerops/billing_test.exs b/test/towerops/billing_test.exs
index c225f1a7..f7acbd92 100644
--- a/test/towerops/billing_test.exs
+++ b/test/towerops/billing_test.exs
@@ -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
diff --git a/test/towerops/organizations/organization_test.exs b/test/towerops/organizations/organization_test.exs
index e8d1428f..6c3f7d5b 100644
--- a/test/towerops/organizations/organization_test.exs
+++ b/test/towerops/organizations/organization_test.exs
@@ -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
diff --git a/test/towerops/organizations/subscription_limits_test.exs b/test/towerops/organizations/subscription_limits_test.exs
index ef99cd08..dc374689 100644
--- a/test/towerops/organizations/subscription_limits_test.exs
+++ b/test/towerops/organizations/subscription_limits_test.exs
@@ -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
diff --git a/test/towerops_web/live/admin/org_live/index_test.exs b/test/towerops_web/live/admin/org_live/index_test.exs
index d46927e5..8bff983e 100644
--- a/test/towerops_web/live/admin/org_live/index_test.exs
+++ b/test/towerops_web/live/admin/org_live/index_test.exs
@@ -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