diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index fed629a6..94ac5b7c 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,3 +1,24 @@
+2026-03-06
+feat: dynamic global pricing configuration with Stripe integration
+ - Add default_free_devices and default_price_per_device to ApplicationSettings
+ - Update default price from $1.00 to $2.00/device/month
+ - Add Billing.default_free_devices/0 and default_price_per_device/0 with fallbacks
+ - Add StripeClient.create_price/1 for metered billing Price creation
+ - Add StripeClient.update_subscription_price/2 for price migrations
+ - Add Billing.migrate_all_subscriptions_to_price/1 with best-effort migration
+ - Add Admin.update_global_pricing/3 with validation and audit logging
+ - Add global defaults UI on /admin/organizations with confirmation dialog
+ - Update marketing pages to reflect $2/device/month pricing
+Files: priv/repo/migrations/*seed_billing_settings.exs,
+ lib/towerops/settings/application_setting.ex,
+ lib/towerops/billing.ex,
+ lib/towerops/billing/stripe_client.ex,
+ lib/towerops/admin.ex,
+ lib/towerops/admin/audit_log.ex,
+ lib/towerops_web/live/admin/org_live/index.ex,
+ lib/towerops_web/live/admin/org_live/index.html.heex,
+ lib/towerops_web/controllers/page_html/home.html.heex
+
2026-03-06
feat: per-organization billing override admin UI
- Add custom_free_device_limit and custom_price_per_device nullable fields to organizations
diff --git a/config/test.exs b/config/test.exs
index 1e24076e..b3e8b69b 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -94,4 +94,6 @@ config :towerops,
# Reduce debounce delay in tests for faster test execution (50ms vs 500ms in prod)
agent_channel_debounce_ms: 50,
# Reduce device deletion delay for faster tests (10ms vs 500ms in prod)
- device_deletion_delay_ms: 10
+ device_deletion_delay_ms: 10,
+ # Stripe test configuration
+ stripe_product_id: "prod_test"
diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex
index 0a582750..b39e5a78 100644
--- a/lib/towerops/admin.ex
+++ b/lib/towerops/admin.ex
@@ -269,4 +269,148 @@ defmodule Towerops.Admin do
|> preload([:superuser, :target_user])
|> Repo.all()
end
+
+ ## Global Pricing Management
+
+ @doc """
+ Update global pricing settings.
+
+ Creates new Stripe Price if price changed, migrates all active subscriptions,
+ updates ApplicationSettings, and creates audit log.
+
+ Returns {:ok, migration_report} or {:error, reason}.
+ """
+ def update_global_pricing(attrs, superuser_id, ip_address) do
+ alias Towerops.Billing
+ alias Towerops.Billing.StripeClient
+ alias Towerops.Settings
+ alias Towerops.Settings.ApplicationSetting
+
+ with {:ok, validated} <- validate_pricing_attrs(attrs),
+ old_values = get_current_pricing(),
+ {:ok, new_price_id, migration_report} <- maybe_update_stripe_price(validated, old_values),
+ :ok <- update_pricing_settings(validated, new_price_id),
+ :ok <- create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
+ {:ok, migration_report}
+ end
+ end
+
+ defp validate_pricing_attrs(attrs) do
+ free_devices = Map.get(attrs, "default_free_devices")
+ price = Map.get(attrs, "default_price_per_device")
+
+ with {:ok, free} <- validate_free_devices(free_devices),
+ {:ok, price} <- validate_price(price) do
+ {:ok, %{free_devices: free, price: price}}
+ end
+ end
+
+ defp validate_free_devices(nil), do: {:ok, nil}
+
+ defp validate_free_devices(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, _} when int > 0 and int < 10_000 -> {:ok, int}
+ _ -> {:error, :invalid_free_devices}
+ end
+ end
+
+ defp validate_price(nil), do: {:ok, nil}
+
+ defp validate_price(value) when is_binary(value) do
+ case Decimal.parse(value) do
+ {decimal, _} ->
+ if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
+ Decimal.compare(decimal, "999.99") in [:lt, :eq] do
+ {:ok, value}
+ else
+ {:error, :invalid_price}
+ end
+
+ :error ->
+ {:error, :invalid_price}
+ end
+ end
+
+ defp get_current_pricing do
+ alias Towerops.Settings
+
+ %{
+ free_devices: Settings.get_setting("default_free_devices"),
+ price: Settings.get_setting("default_price_per_device")
+ }
+ end
+
+ defp maybe_update_stripe_price(validated, old_values) do
+ alias Towerops.Billing
+ alias Towerops.Billing.StripeClient
+
+ price_changed = validated.price && validated.price != old_values.price
+
+ if price_changed do
+ case StripeClient.create_price(validated.price) do
+ {:ok, %{"id" => price_id}} ->
+ migration_report = Billing.migrate_all_subscriptions_to_price(price_id)
+ {:ok, price_id, migration_report}
+
+ {:error, reason} ->
+ {:error, {:stripe_error, reason}}
+ end
+ else
+ {:ok, nil, %{total: 0, succeeded: 0, failed: 0, failures: []}}
+ end
+ end
+
+ defp update_pricing_settings(validated, new_price_id) do
+ alias Towerops.Settings
+ alias Towerops.Settings.ApplicationSetting
+
+ if validated.free_devices do
+ Settings.update_setting("default_free_devices", validated.free_devices)
+ end
+
+ if validated.price do
+ Settings.update_setting("default_price_per_device", validated.price)
+ end
+
+ if new_price_id do
+ # Create setting if doesn't exist
+ case Settings.get_setting_record("stripe_price_id") do
+ nil ->
+ %ApplicationSetting{}
+ |> ApplicationSetting.changeset(%{
+ key: "stripe_price_id",
+ value: new_price_id,
+ value_type: "string",
+ description: "Current Stripe Price ID for metered billing"
+ })
+ |> Repo.insert()
+
+ _setting ->
+ Settings.update_setting("stripe_price_id", new_price_id)
+ end
+ end
+
+ :ok
+ end
+
+ defp create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
+ metadata = %{
+ old_free_devices: old_values.free_devices,
+ new_free_devices: validated.free_devices,
+ old_price: old_values.price,
+ new_price: validated.price,
+ migration_total: migration_report.total,
+ migration_succeeded: migration_report.succeeded,
+ migration_failed: migration_report.failed
+ }
+
+ create_audit_log(%{
+ action: "global_pricing_updated",
+ superuser_id: superuser_id,
+ ip_address: ip_address,
+ metadata: metadata
+ })
+
+ :ok
+ end
end
diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex
index ce640d0c..75c72da7 100644
--- a/lib/towerops/admin/audit_log.ex
+++ b/lib/towerops/admin/audit_log.ex
@@ -47,6 +47,7 @@ defmodule Towerops.Admin.AuditLog do
"org_delete",
"agent_debug_enable",
"agent_debug_disable",
+ "global_pricing_updated",
# User data access
"user_data_viewed",
"user_data_exported",
diff --git a/lib/towerops/billing.ex b/lib/towerops/billing.ex
index f9bc2fb7..9aeff595 100644
--- a/lib/towerops/billing.ex
+++ b/lib/towerops/billing.ex
@@ -3,21 +3,55 @@ defmodule Towerops.Billing do
Billing context for subscription and payment management.
Integrates with Stripe for metered billing based on device count.
- Pricing: $1/device/month after first 10 free devices.
+ Pricing: $2/device/month after first 10 free devices (configurable via ApplicationSettings).
"""
alias Towerops.Billing.StripeClient
alias Towerops.Organizations.Organization
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Repo
+ alias Towerops.Settings
require Logger
@default_free_devices 10
- @default_price_per_device Decimal.new("1.00")
+ @default_price_per_device Decimal.new("2.00")
# Public API
+ @doc """
+ Returns the default number of free devices from ApplicationSettings.
+ Falls back to module attribute (@default_free_devices = 10) if not configured.
+
+ Expected `value_type`: "integer"
+ """
+ def default_free_devices do
+ Settings.get_setting("default_free_devices") || @default_free_devices
+ end
+
+ @doc """
+ Returns the default price per device from ApplicationSettings.
+ Falls back to module attribute (@default_price_per_device = $2.00) if not configured.
+
+ Expected `value_type`: "decimal"
+ """
+ def default_price_per_device do
+ case Settings.get_setting("default_price_per_device") do
+ nil -> @default_price_per_device
+ %Decimal{} = decimal -> decimal
+ end
+ end
+
+ @doc """
+ Returns the Stripe Price ID from ApplicationSettings.
+ Falls back to STRIPE_PRICE_ID environment variable if not configured.
+
+ Expected `value_type`: "string"
+ """
+ def stripe_price_id do
+ Settings.get_setting("stripe_price_id") || Application.get_env(:towerops, :stripe_price_id)
+ end
+
@doc """
Create Stripe checkout session for organization to start paid subscription.
@@ -79,18 +113,18 @@ defmodule Towerops.Billing do
@doc """
Returns the effective number of free devices for an organization.
- Uses custom override if set, otherwise the default (10).
+ Uses custom override if set, otherwise the global default from ApplicationSettings.
"""
def effective_free_device_count(%Organization{} = org) do
- org.custom_free_device_limit || @default_free_devices
+ 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).
+ Uses custom override if set, otherwise the global default from ApplicationSettings.
"""
def effective_price_per_device(%Organization{} = org) do
- org.custom_price_per_device || @default_price_per_device
+ org.custom_price_per_device || default_price_per_device()
end
@doc """
@@ -193,6 +227,49 @@ defmodule Towerops.Billing do
end
end
+ @doc """
+ Migrate all active subscriptions to new price.
+
+ Best-effort migration: continues on individual failures, returns detailed report.
+
+ Returns map with:
+ - total: number of subscriptions attempted
+ - succeeded: number successfully migrated
+ - failed: number of failures
+ - failures: list of {:error, org, reason} tuples
+ """
+ def migrate_all_subscriptions_to_price(new_price_id) do
+ import Ecto.Query
+
+ orgs =
+ Repo.all(
+ from o in Organization,
+ where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id)
+ )
+
+ results =
+ Enum.map(orgs, fn org ->
+ case StripeClient.update_subscription_price(org.stripe_subscription_id, new_price_id) do
+ {:ok, _} ->
+ {:ok, org}
+
+ {:error, reason} ->
+ Logger.warning("Failed to migrate subscription for org #{org.id}: #{inspect(reason)}")
+ {:error, org, reason}
+ end
+ end)
+
+ successes = Enum.count(results, &match?({:ok, _}, &1))
+ failures = Enum.filter(results, &match?({:error, _, _}, &1))
+
+ %{
+ total: length(orgs),
+ succeeded: successes,
+ failed: length(failures),
+ failures: failures
+ }
+ end
+
defp update_billing_sync(organization, device_count) do
organization
|> Organization.changeset(%{
diff --git a/lib/towerops/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex
index f98fbc34..06ed7242 100644
--- a/lib/towerops/billing/stripe_client.ex
+++ b/lib/towerops/billing/stripe_client.ex
@@ -125,6 +125,52 @@ defmodule Towerops.Billing.StripeClient do
end
end
+ # Price Management
+
+ @doc """
+ Create a new Price object for metered billing.
+
+ Returns {:ok, price_object} or {:error, reason}.
+ """
+ def create_price(unit_amount_decimal) when is_binary(unit_amount_decimal) do
+ params = %{
+ product: stripe_product_id(),
+ currency: "usd",
+ unit_amount_decimal: unit_amount_decimal,
+ recurring: %{
+ interval: "month",
+ usage_type: "metered",
+ aggregate_usage: "max"
+ },
+ billing_scheme: "per_unit",
+ metadata: %{
+ created_by: "towerops_admin_ui",
+ created_at: DateTime.to_iso8601(DateTime.utc_now())
+ }
+ }
+
+ post("/v1/prices", params)
+ end
+
+ @doc """
+ Update subscription to use new price.
+
+ Fetches subscription to get item ID, then updates with proration_behavior: "none"
+ so price change takes effect at next billing cycle.
+ """
+ def update_subscription_price(subscription_id, new_price_id) do
+ with {:ok, sub} <- get_subscription(subscription_id) do
+ item_id = sub |> get_in(["items", "data"]) |> List.first() |> Map.get("id")
+
+ params = %{
+ items: [%{id: item_id, price: new_price_id}],
+ proration_behavior: "none"
+ }
+
+ post("/v1/subscriptions/#{subscription_id}", params)
+ end
+ end
+
# HTTP Request Helpers
defp get(path) do
@@ -255,4 +301,8 @@ defmodule Towerops.Billing.StripeClient do
defp stripe_price_id do
Application.fetch_env!(:towerops, :stripe_price_id)
end
+
+ defp stripe_product_id do
+ Application.fetch_env!(:towerops, :stripe_product_id)
+ end
end
diff --git a/lib/towerops/settings/application_setting.ex b/lib/towerops/settings/application_setting.ex
index e5c5d2fa..d8efa463 100644
--- a/lib/towerops/settings/application_setting.ex
+++ b/lib/towerops/settings/application_setting.ex
@@ -35,7 +35,7 @@ defmodule Towerops.Settings.ApplicationSetting do
setting
|> cast(attrs, [:key, :value, :value_type, :description])
|> validate_required([:key, :value_type])
- |> validate_inclusion(:value_type, ["string", "integer", "uuid", "boolean", "json"])
+ |> validate_inclusion(:value_type, ["string", "integer", "uuid", "boolean", "json", "decimal"])
|> unique_constraint(:key)
end
@@ -72,4 +72,11 @@ defmodule Towerops.Settings.ApplicationSetting do
{:error, _} -> nil
end
end
+
+ def parse_value(%__MODULE__{value: value, value_type: "decimal"}) do
+ case Decimal.parse(value) do
+ {decimal, _remainder} -> decimal
+ :error -> nil
+ end
+ end
end
diff --git a/lib/towerops_web/controllers/page_html/home.html.heex b/lib/towerops_web/controllers/page_html/home.html.heex
index 4223e97c..ab1345be 100644
--- a/lib/towerops_web/controllers/page_html/home.html.heex
+++ b/lib/towerops_web/controllers/page_html/home.html.heex
@@ -327,13 +327,13 @@
- $3
+ $2/device/month
First 10 devices are
free forever
- — all features, no limits. Pay $3/device/month only after that.
+ — all features, no limits. Pay $2/device/month only after that.
diff --git a/lib/towerops_web/live/admin/org_live/index.ex b/lib/towerops_web/live/admin/org_live/index.ex
index f5dcfc8f..cf1b47e0 100644
--- a/lib/towerops_web/live/admin/org_live/index.ex
+++ b/lib/towerops_web/live/admin/org_live/index.ex
@@ -4,12 +4,18 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
"""
use ToweropsWeb, :live_view
+ import Ecto.Query
+
alias Towerops.Admin
+ alias Towerops.Billing
alias Towerops.Organizations.Organization
@impl true
def mount(_params, _session, socket) do
orgs = Admin.list_all_organizations()
+ default_free_devices = Billing.default_free_devices()
+ default_price = Billing.default_price_per_device()
+ active_sub_count = count_active_subscriptions()
ip =
case get_connect_info(socket, :peer_data) do
@@ -24,17 +30,41 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|> assign(:organizations, orgs)
|> assign(:client_ip, ip)
|> assign(:editing_org, nil)
- |> assign(:override_form, nil)}
+ |> assign(:override_form, nil)
+ |> assign(:default_free_devices, default_free_devices)
+ |> assign(:default_price, default_price)
+ |> assign(:active_sub_count, active_sub_count)
+ |> assign(:editing_global, false)
+ |> assign(:global_form, nil)
+ |> assign(:show_confirm, false)
+ |> assign(:pending_pricing_params, nil)}
end
@impl true
+ def handle_params(%{"edit_global" => "true"} = _params, _url, socket) do
+ form =
+ to_form(%{
+ "default_free_devices" => socket.assigns.default_free_devices,
+ "default_price_per_device" => Decimal.to_string(socket.assigns.default_price)
+ })
+
+ {:noreply,
+ socket
+ |> assign(:editing_global, true)
+ |> assign(:global_form, form)
+ |> assign(:show_confirm, false)
+ |> assign(:editing_org, nil)
+ |> assign(:override_form, nil)}
+ end
+
def handle_params(params, _url, socket) do
case params["edit"] do
nil ->
{:noreply,
socket
|> assign(:editing_org, nil)
- |> assign(:override_form, nil)}
+ |> assign(:override_form, nil)
+ |> assign(:editing_global, false)}
org_id ->
org = Enum.find(socket.assigns.organizations, &(&1.id == org_id))
@@ -45,12 +75,14 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
{:noreply,
socket
|> assign(:editing_org, org)
- |> assign(:override_form, to_form(changeset))}
+ |> assign(:override_form, to_form(changeset))
+ |> assign(:editing_global, false)}
else
{:noreply,
socket
|> assign(:editing_org, nil)
- |> assign(:override_form, nil)}
+ |> assign(:override_form, nil)
+ |> assign(:editing_global, false)}
end
end
end
@@ -128,4 +160,107 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
{:noreply, put_flash(socket, :error, t_admin("Failed to delete organization"))}
end
end
+
+ def handle_event("edit_global_defaults", _params, socket) do
+ {:noreply, push_patch(socket, to: ~p"/admin/organizations?edit_global=true")}
+ end
+
+ def handle_event("validate_global_pricing", %{"global_pricing" => params}, socket) do
+ errors = validate_global_pricing_params(params)
+ form = to_form(params, errors: errors)
+
+ {:noreply, assign(socket, :global_form, form)}
+ end
+
+ def handle_event("save_global_pricing", %{"global_pricing" => params}, socket) do
+ case validate_global_pricing_params(params) do
+ [] ->
+ # Valid - show confirmation
+ {:noreply,
+ socket
+ |> assign(:show_confirm, true)
+ |> assign(:pending_pricing_params, params)}
+
+ errors ->
+ form = to_form(params, errors: errors)
+ {:noreply, assign(socket, :global_form, form)}
+ end
+ end
+
+ def handle_event("confirm_pricing_update", _params, socket) do
+ params = socket.assigns.pending_pricing_params
+ superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
+
+ case Admin.update_global_pricing(params, superuser.id, socket.assigns.client_ip) do
+ {:ok, result} ->
+ {:noreply,
+ socket
+ |> put_flash(
+ :info,
+ "Pricing updated successfully. #{result.succeeded}/#{result.total} subscriptions migrated."
+ )
+ |> push_patch(to: ~p"/admin/organizations")}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, "Failed to update pricing: #{inspect(reason)}")}
+ end
+ end
+
+ def handle_event("cancel_global_edit", _params, socket) do
+ {:noreply, push_patch(socket, to: ~p"/admin/organizations")}
+ end
+
+ defp validate_global_pricing_params(params) do
+ errors = []
+
+ errors =
+ case Map.get(params, "default_free_devices") do
+ nil ->
+ errors
+
+ value ->
+ case Integer.parse(value) do
+ {int, _} when int > 0 and int < 10_000 ->
+ errors
+
+ _ ->
+ [
+ {:default_free_devices, {"must be greater than 0 and less than 10,000", []}}
+ | errors
+ ]
+ end
+ end
+
+ errors =
+ case Map.get(params, "default_price_per_device") do
+ nil ->
+ errors
+
+ value ->
+ case Decimal.parse(value) do
+ {decimal, _} ->
+ if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
+ Decimal.compare(decimal, "999.99") in [:lt, :eq] do
+ errors
+ else
+ [{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
+ end
+
+ :error ->
+ [{:default_price_per_device, {"must be a valid decimal", []}} | errors]
+ end
+ end
+
+ errors
+ end
+
+ defp count_active_subscriptions do
+ alias Towerops.Repo
+
+ Repo.one(
+ from o in Organization,
+ where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
+ select: count(o.id)
+ )
+ 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 848e952e..9ff86c4f 100644
--- a/lib/towerops_web/live/admin/org_live/index.html.heex
+++ b/lib/towerops_web/live/admin/org_live/index.html.heex
@@ -5,6 +5,54 @@
{t_admin("Manage organizations")}
+
+
+
+
+
+ Global Billing Defaults
+
+
+ Default pricing for all organizations (can be overridden per-org)
+