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=""> - +
+ + +
+ + <%!-- Billing Override Edit Panel --%> + <%= if @editing_org do %> +
+
+

+ {t_admin("Billing Overrides")} — {@editing_org.name} +

+ +
+ + <.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")} + + +
+ +
+ <% 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