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/test/towerops/admin_test.exs b/test/towerops/admin_test.exs index 9734878a..50d2408c 100644 --- a/test/towerops/admin_test.exs +++ b/test/towerops/admin_test.exs @@ -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