feat: add Admin.update_global_pricing with Stripe integration

This commit is contained in:
Graham McIntire 2026-03-06 13:55:12 -06:00
parent ee918c2eda
commit 4f102f4acc
No known key found for this signature in database
3 changed files with 261 additions and 0 deletions

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

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