Merge branch 'feature/dynamic-global-pricing'

This commit is contained in:
Graham McIntire 2026-03-06 14:04:12 -06:00
commit ac2547e7c9
No known key found for this signature in database
17 changed files with 1128 additions and 18 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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(%{

View file

@ -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

View file

@ -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

View file

@ -327,13 +327,13 @@
<div class="mt-16 mx-auto max-w-xl">
<div class="rounded-2xl bg-white dark:bg-slate-800 p-8 shadow-lg dark:shadow-black/30 ring-2 ring-blue-600 text-center">
<div class="mt-2">
<span class="text-5xl font-bold text-slate-900 dark:text-white">$3</span>
<span class="text-5xl font-bold text-slate-900 dark:text-white">$2</span>
<span class="text-lg text-slate-500 dark:text-slate-400">/device/month</span>
</div>
<p class="mt-3 text-base text-slate-600 dark:text-slate-300">
First 10 devices are
<strong class="text-slate-900 dark:text-white">free forever</strong>
— all features, no limits. Pay $3/device/month only after that.
— all features, no limits. Pay $2/device/month only after that.
</p>
<div class="mt-10 grid grid-cols-2 gap-x-8 gap-y-3 text-sm text-slate-600 dark:text-slate-300 text-left max-w-md mx-auto">

View file

@ -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

View file

@ -5,6 +5,54 @@
<p class="text-gray-600 dark:text-gray-400">{t_admin("Manage organizations")}</p>
</div>
<!-- Global Billing Defaults Card -->
<div
class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800"
data-test="global-defaults-card"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
Global Billing Defaults
</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Default pricing for all organizations (can be overridden per-org)
</p>
</div>
<button
type="button"
phx-click="edit_global_defaults"
data-test="edit-global-defaults"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Edit Defaults
</button>
</div>
<dl class="mt-6 grid grid-cols-2 gap-6">
<div>
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">Free Devices</dt>
<dd
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
data-test="default-free-devices"
>
{@default_free_devices}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">
Price Per Device
</dt>
<dd
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
data-test="default-price"
>
${@default_price}/mo
</dd>
</div>
</dl>
</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>
@ -130,5 +178,128 @@
</.form>
</div>
<% end %>
<%!-- Edit Global Defaults Modal --%>
<%= if @editing_global do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
data-test="global-defaults-modal"
>
<div class="flex min-h-screen items-center justify-center p-4">
<div
class="fixed inset-0 bg-slate-500 bg-opacity-75"
phx-click="cancel_global_edit"
>
</div>
<div class="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
Edit Global Billing Defaults
</h3>
<.form
for={@global_form}
phx-change="validate_global_pricing"
phx-submit="save_global_pricing"
data-test="global-defaults-form"
class="mt-6 space-y-4"
>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
Free Devices
</label>
<input
type="number"
name="global_pricing[default_free_devices]"
value={@global_form.data["default_free_devices"]}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
/>
<%= if error = @global_form.errors[:default_free_devices] do %>
<p
id="global_pricing_default_free_devices-error"
class="mt-1 text-sm text-red-600"
>
{elem(error, 0)}
</p>
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
Price Per Device (USD/month)
</label>
<input
type="number"
step="0.01"
name="global_pricing[default_price_per_device]"
value={@global_form.data["default_price_per_device"]}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
/>
<%= if error = @global_form.errors[:default_price_per_device] do %>
<p
id="global_pricing_default_price_per_device-error"
class="mt-1 text-sm text-red-600"
>
{elem(error, 0)}
</p>
<% end %>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
phx-click="cancel_global_edit"
data-test="cancel-global-edit"
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Save Changes
</button>
</div>
</.form>
<%!-- Confirmation Dialog --%>
<%= if @show_confirm do %>
<div
class="absolute inset-0 flex items-center justify-center bg-slate-900/50"
data-test="confirm-pricing-change"
>
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h4 class="text-lg font-medium text-slate-900 dark:text-white">
Confirm Price Change
</h4>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
This will update {@active_sub_count} active subscriptions to the new price.
Changes take effect at the next billing cycle.
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
phx-click="cancel_global_edit"
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
>
Cancel
</button>
<button
type="button"
phx-click="confirm_pricing_update"
data-test="confirm-pricing-update"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Confirm Update
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.admin>

View file

@ -0,0 +1,22 @@
defmodule Towerops.Repo.Migrations.SeedBillingSettings do
use Ecto.Migration
def up do
execute """
INSERT INTO application_settings (id, key, value, value_type, description, inserted_at, updated_at)
VALUES
(gen_random_uuid(), 'default_free_devices', '10', 'integer',
'Number of free devices included in all subscriptions', NOW(), NOW()),
(gen_random_uuid(), 'default_price_per_device', '2.00', 'decimal',
'Price per device per month (USD) after free tier', NOW(), NOW())
ON CONFLICT (key) DO NOTHING;
"""
end
def down do
execute """
DELETE FROM application_settings
WHERE key IN ('default_free_devices', 'default_price_per_device');
"""
end
end

View file

@ -1,3 +1,8 @@
2026-03-06 — Pricing Update
* Updated pricing to $2/device/month (previously $3 on marketing, $1 in code)
* First 10 devices remain free with all features
* Improved billing flexibility with per-organization customization
2026-03-06 — Per-Organization Billing Overrides
* Per-organization customization of free device limits and per-device pricing
* Organization settings page now reflects custom billing terms when set

View file

@ -780,4 +780,120 @@ defmodule Towerops.AdminTest do
assert found_org.device_count == 0
end
end
describe "update_global_pricing/3" do
test "updates settings, creates Stripe price, migrates subscriptions, and audits" do
import Towerops.OrganizationsFixtures
alias Towerops.Admin.AuditLog
alias Towerops.Billing.StripeClient
alias Towerops.Settings
alias Towerops.Settings.ApplicationSetting
superuser = user_fixture(%{superuser: true})
user = user_fixture()
_org1 = organization_fixture(user.id, %{subscription_status: "active", stripe_subscription_id: "sub_1"})
_org2 = organization_fixture(user.id, %{subscription_status: "active", stripe_subscription_id: "sub_2"})
# Seed current settings
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_free_devices", value: "10", value_type: "integer"})
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_price_per_device", value: "2.00", value_type: "string"})
# Mock Stripe
Req.Test.stub(StripeClient, fn conn ->
cond do
conn.method == "POST" and String.contains?(conn.request_path, "/v1/prices") ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "price_new123"}))
conn.method == "GET" and String.contains?(conn.request_path, "/v1/subscriptions") ->
# Return subscription with items
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_test",
"items" => %{"data" => [%{"id" => "si_test", "price" => %{"id" => "price_old"}}]}
})
)
String.contains?(conn.request_path, "/v1/subscriptions") ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "sub_test"}))
true ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(404, Jason.encode!(%{}))
end
end)
attrs = %{
"default_free_devices" => "15",
"default_price_per_device" => "3.00"
}
assert {:ok, result} = Admin.update_global_pricing(attrs, superuser.id, "127.0.0.1")
# Verify settings updated
assert Settings.get_setting("default_free_devices") == 15
assert Settings.get_setting("default_price_per_device") == "3.00"
assert Settings.get_setting("stripe_price_id") == "price_new123"
# Verify migration results
assert result.total == 2
assert result.succeeded == 2
# Verify audit log
log =
Repo.one(
from al in AuditLog, where: al.action == "global_pricing_updated", order_by: [desc: al.inserted_at], limit: 1
)
assert log.superuser_id == superuser.id
assert log.ip_address.address == "127.0.0.1"
assert log.metadata["old_free_devices"] == 10
assert log.metadata["new_free_devices"] == 15
assert log.metadata["old_price"] == "2.00"
assert log.metadata["new_price"] == "3.00"
assert log.metadata["migration_succeeded"] == 2
end
test "returns ok when price unchanged and no migration needed" do
alias Towerops.Settings
alias Towerops.Settings.ApplicationSetting
superuser = user_fixture(%{superuser: true})
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_free_devices", value: "10", value_type: "integer"})
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_price_per_device", value: "2.00", value_type: "string"})
# Only change free devices, not price
attrs = %{
"default_free_devices" => "15",
"default_price_per_device" => "2.00"
}
assert {:ok, result} = Admin.update_global_pricing(attrs, superuser.id, "127.0.0.1")
# No Stripe price created since price unchanged
assert is_nil(Settings.get_setting("stripe_price_id"))
assert result.total == 0
end
test "validates pricing values" do
superuser = user_fixture(%{superuser: true})
# Invalid free devices (negative)
assert {:error, :invalid_free_devices} =
Admin.update_global_pricing(%{"default_free_devices" => "-5"}, superuser.id, "127.0.0.1")
# Invalid price (too high)
assert {:error, :invalid_price} =
Admin.update_global_pricing(%{"default_price_per_device" => "1000.00"}, superuser.id, "127.0.0.1")
end
end
end

View file

@ -315,4 +315,104 @@ defmodule Towerops.Billing.StripeClientTest do
StripeClient.create_customer(organization)
end
end
describe "create_price/1" do
test "creates metered billing price at specified amount" do
Req.Test.stub(StripeClient, fn conn ->
assert conn.method == "POST"
assert conn.request_path == "/v1/prices"
# Verify form params
{:ok, body, conn} = Plug.Conn.read_body(conn)
params = URI.decode_query(body)
assert params["unit_amount_decimal"] == "2.50"
assert params["currency"] == "usd"
assert params["recurring[interval]"] == "month"
assert params["recurring[usage_type]"] == "metered"
assert params["recurring[aggregate_usage]"] == "max"
assert params["billing_scheme"] == "per_unit"
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "price_test123",
"object" => "price",
"unit_amount_decimal" => "2.50"
})
)
end)
assert {:ok, %{"id" => "price_test123"}} = StripeClient.create_price("2.50")
end
test "returns error on API failure" do
Req.Test.stub(StripeClient, fn conn ->
error = %{"error" => %{"message" => "Invalid amount"}}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, Jason.encode!(error))
end)
assert {:error, {:bad_request, "Invalid amount"}} = StripeClient.create_price("invalid")
end
end
describe "update_subscription_price/2" do
test "updates subscription to new price" do
# First stub: get subscription
Req.Test.stub(StripeClient, fn conn ->
if conn.method == "GET" and String.contains?(conn.request_path, "/v1/subscriptions/sub_test") do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_test",
"items" => %{
"data" => [%{"id" => "si_test123", "price" => %{"id" => "price_old"}}]
}
})
)
else
# Second stub: update subscription
{:ok, body, conn} = Plug.Conn.read_body(conn)
params = URI.decode_query(body)
assert params["items[0][id]"] == "si_test123"
assert params["items[0][price]"] == "price_new123"
assert params["proration_behavior"] == "none"
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_test",
"items" => %{"data" => [%{"price" => %{"id" => "price_new123"}}]}
})
)
end
end)
assert {:ok, %{"id" => "sub_test"}} =
StripeClient.update_subscription_price("sub_test", "price_new123")
end
test "returns error when subscription not found" do
Req.Test.stub(StripeClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
404,
Jason.encode!(%{"error" => %{"message" => "No such subscription"}})
)
end)
assert {:error, _} = StripeClient.update_subscription_price("sub_invalid", "price_new")
end
end
end

View file

@ -10,6 +10,7 @@ defmodule Towerops.BillingTest do
alias Towerops.Billing.StripeClient
alias Towerops.Organizations
alias Towerops.Organizations.Organization
alias Towerops.Settings.ApplicationSetting
setup do
user = user_fixture()
@ -204,7 +205,7 @@ defmodule Towerops.BillingTest do
describe "estimated_monthly_cost/1" do
test "returns correct cost calculation", %{organization: organization} do
# Create 25 devices (15 billable)
# Create 25 devices (15 billable at $2.00/device = $30.00)
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
@ -212,7 +213,7 @@ defmodule Towerops.BillingTest do
assert {:ok, %{devices: 25, billable: 15, cost_usd: cost}} =
Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(cost, Decimal.new(15))
assert Decimal.equal?(cost, Decimal.new("30.00"))
end
test "returns zero cost for free tier usage", %{organization: organization} do
@ -257,7 +258,7 @@ defmodule Towerops.BillingTest do
end
test "returns effective values in estimated cost map", %{organization: organization} do
# Default org (no overrides): 10 free, $1.00/device
# Default org (no overrides): 10 free, $2.00/device
for _ <- 1..15 do
device_fixture(%{organization_id: organization.id})
end
@ -268,7 +269,7 @@ defmodule Towerops.BillingTest do
price_per_device: price
}} = Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(price, Decimal.new("1.00"))
assert Decimal.equal?(price, Decimal.new("2.00"))
end
end
@ -387,4 +388,162 @@ defmodule Towerops.BillingTest do
assert updated_org.payment_method_status == "requires_action"
end
end
describe "default_free_devices/0" do
test "returns value from ApplicationSettings when set" do
# Seed setting
{:ok, _} =
Repo.insert(%ApplicationSetting{
key: "default_free_devices",
value: "15",
value_type: "integer"
})
assert Billing.default_free_devices() == 15
end
test "falls back to module attribute when setting not found" do
# Ensure setting doesn't exist
Repo.delete_all(from s in ApplicationSetting, where: s.key == "default_free_devices")
assert Billing.default_free_devices() == 10
end
end
describe "default_price_per_device/0" do
test "returns value from ApplicationSettings when set" do
{:ok, _} =
Repo.insert(%ApplicationSetting{
key: "default_price_per_device",
value: "3.50",
value_type: "decimal"
})
assert Billing.default_price_per_device() == Decimal.new("3.50")
end
test "falls back to module attribute when setting not found" do
Repo.delete_all(
from s in ApplicationSetting,
where: s.key == "default_price_per_device"
)
assert Billing.default_price_per_device() == Decimal.new("2.00")
end
end
describe "stripe_price_id/0" do
test "returns value from ApplicationSettings when set" do
{:ok, _} =
Repo.insert(%ApplicationSetting{
key: "stripe_price_id",
value: "price_test123",
value_type: "string"
})
assert Billing.stripe_price_id() == "price_test123"
end
test "falls back to env var when setting not found" do
Repo.delete_all(from s in ApplicationSetting, where: s.key == "stripe_price_id")
# Env var is set in test config to "price_test_fake"
assert Billing.stripe_price_id() == "price_test_fake"
end
end
describe "migrate_all_subscriptions_to_price/1" do
test "migrates all active subscriptions and reports results" do
# Create test orgs with active subscriptions
user = user_fixture()
_org1 = organization_fixture(user.id, %{subscription_status: "active", stripe_subscription_id: "sub_1"})
_org2 = organization_fixture(user.id, %{subscription_status: "active", stripe_subscription_id: "sub_2"})
org3 = organization_fixture(user.id, %{subscription_status: "active", stripe_subscription_id: "sub_3"})
_org4 = organization_fixture(user.id, %{subscription_status: "canceled", stripe_subscription_id: "sub_4"})
# Mock Stripe responses
Req.Test.stub(StripeClient, fn conn ->
cond do
conn.method == "GET" and String.contains?(conn.request_path, "sub_1") ->
# GET subscription - success
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_1",
"items" => %{"data" => [%{"id" => "si_1", "price" => %{"id" => "price_old"}}]}
})
)
conn.method == "POST" and String.contains?(conn.request_path, "sub_1") ->
# POST update - success
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "sub_1"}))
conn.method == "GET" and String.contains?(conn.request_path, "sub_2") ->
# GET subscription - success
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_2",
"items" => %{"data" => [%{"id" => "si_2", "price" => %{"id" => "price_old"}}]}
})
)
conn.method == "POST" and String.contains?(conn.request_path, "sub_2") ->
# POST update - success
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "sub_2"}))
conn.method == "GET" and String.contains?(conn.request_path, "sub_3") ->
# GET subscription - success (but update will fail)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"id" => "sub_3",
"items" => %{"data" => [%{"id" => "si_3", "price" => %{"id" => "price_old"}}]}
})
)
conn.method == "POST" and String.contains?(conn.request_path, "sub_3") ->
# POST update - failure
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
400,
Jason.encode!(%{"error" => %{"message" => "Subscription cannot be modified"}})
)
true ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(404, Jason.encode!(%{}))
end
end)
result = Billing.migrate_all_subscriptions_to_price("price_new123")
assert result.total == 3
assert result.succeeded == 2
assert result.failed == 1
assert length(result.failures) == 1
assert [{:error, ^org3, _reason}] = result.failures
end
test "returns empty results when no active subscriptions" do
result = Billing.migrate_all_subscriptions_to_price("price_new123")
assert result.total == 0
assert result.succeeded == 0
assert result.failed == 0
assert result.failures == []
end
end
end

View file

@ -50,6 +50,27 @@ defmodule Towerops.SettingsTest do
{:ok, _setting} = create_setting("test_invalid_json", "not json", "json")
assert Settings.get_setting("test_invalid_json") == nil
end
test "parses decimal values correctly" do
{:ok, _setting} = create_setting("test_decimal", "123.45", "decimal")
result = Settings.get_setting("test_decimal")
assert result == Decimal.new("123.45")
assert %Decimal{} = result
end
test "returns nil for invalid decimal" do
{:ok, _setting} = create_setting("test_invalid_decimal", "not_a_number", "decimal")
assert Settings.get_setting("test_invalid_decimal") == nil
end
test "parses decimal with remainder correctly" do
{:ok, _setting} = create_setting("test_decimal_remainder", "123.45abc", "decimal")
# Decimal.parse stops at invalid chars, returns parsed portion
assert Settings.get_setting("test_decimal_remainder") == Decimal.new("123.45")
end
end
describe "get_setting_record/1" do

View file

@ -165,4 +165,83 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
refute has_element?(view, "#billing-override-form")
end
end
describe "global pricing defaults" do
test "renders global defaults card", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
assert has_element?(view, "[data-test='global-defaults-card']")
assert has_element?(view, "[data-test='default-free-devices']", "10")
assert has_element?(view, "[data-test='default-price']")
end
test "opens edit modal when clicking edit button", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
view
|> element("[data-test='edit-global-defaults']")
|> render_click()
assert has_element?(view, "[data-test='global-defaults-modal']")
assert has_element?(view, "input[name='global_pricing[default_free_devices]']")
assert has_element?(view, "input[name='global_pricing[default_price_per_device]']")
end
test "validates pricing values", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> form("[data-test='global-defaults-form']", %{
global_pricing: %{
default_free_devices: "-5",
default_price_per_device: "1000.00"
}
})
|> render_change()
assert has_element?(view, "#global_pricing_default_free_devices-error")
assert has_element?(view, "#global_pricing_default_price_per_device-error")
end
test "shows confirmation dialog before saving", %{conn: conn, user: user} do
import Towerops.OrganizationsFixtures
_org1 =
organization_fixture(user.id, %{
subscription_status: "active",
stripe_subscription_id: "sub_1"
})
_org2 =
organization_fixture(user.id, %{
subscription_status: "active",
stripe_subscription_id: "sub_2"
})
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> form("[data-test='global-defaults-form']", %{
global_pricing: %{
default_free_devices: "15",
default_price_per_device: "3.00"
}
})
|> render_submit()
# Should show confirmation dialog
assert has_element?(view, "[data-test='confirm-pricing-change']")
end
test "cancels edit without changes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
view
|> element("[data-test='cancel-global-edit']")
|> render_click()
refute has_element?(view, "[data-test='global-defaults-modal']")
assert_patched(view, ~p"/admin/organizations")
end
end
end