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:
parent
1d98cda79d
commit
9aec87636b
14 changed files with 779 additions and 33 deletions
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue