From 7d61d8fb0a490de77e4622e0ec05fb20cf2b7b8e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:26:50 -0600 Subject: [PATCH 01/10] feat: seed global billing settings in application_settings --- .../20260306192611_seed_billing_settings.exs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 priv/repo/migrations/20260306192611_seed_billing_settings.exs diff --git a/priv/repo/migrations/20260306192611_seed_billing_settings.exs b/priv/repo/migrations/20260306192611_seed_billing_settings.exs new file mode 100644 index 00000000..fb7fbf11 --- /dev/null +++ b/priv/repo/migrations/20260306192611_seed_billing_settings.exs @@ -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', 'string', + '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 From a16ce2b9ddc7a479ccdb9f8a40fce98226381487 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:30:30 -0600 Subject: [PATCH 02/10] feat: add decimal type support to ApplicationSetting --- lib/towerops/settings/application_setting.ex | 9 ++++++++- test/towerops/settings_test.exs | 21 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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/test/towerops/settings_test.exs b/test/towerops/settings_test.exs index c96bb33c..ec51b83b 100644 --- a/test/towerops/settings_test.exs +++ b/test/towerops/settings_test.exs @@ -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 From 448ed204012ddc5907f24e15080e6737661d2409 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:40:08 -0600 Subject: [PATCH 03/10] feat: dynamic global billing defaults from ApplicationSettings --- lib/towerops/billing.ex | 46 ++++++++++-- .../20260306192611_seed_billing_settings.exs | 2 +- test/towerops/billing_test.exs | 72 +++++++++++++++++-- 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/lib/towerops/billing.ex b/lib/towerops/billing.ex index f9bc2fb7..1b181cad 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 """ diff --git a/priv/repo/migrations/20260306192611_seed_billing_settings.exs b/priv/repo/migrations/20260306192611_seed_billing_settings.exs index fb7fbf11..145f0e5a 100644 --- a/priv/repo/migrations/20260306192611_seed_billing_settings.exs +++ b/priv/repo/migrations/20260306192611_seed_billing_settings.exs @@ -7,7 +7,7 @@ defmodule Towerops.Repo.Migrations.SeedBillingSettings do 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', 'string', + (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; """ diff --git a/test/towerops/billing_test.exs b/test/towerops/billing_test.exs index f7acbd92..7d5c1507 100644 --- a/test/towerops/billing_test.exs +++ b/test/towerops/billing_test.exs @@ -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,67 @@ 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 end From 3e74ac58975bdec9291420cdf3d3815dfa03b35b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:47:54 -0600 Subject: [PATCH 04/10] feat: add StripeClient.create_price for metered billing --- config/test.exs | 4 +- lib/towerops/billing/stripe_client.ex | 31 ++++++++++++++ test/towerops/billing/stripe_client_test.exs | 45 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) 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/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex index f98fbc34..a9b357e3 100644 --- a/lib/towerops/billing/stripe_client.ex +++ b/lib/towerops/billing/stripe_client.ex @@ -125,6 +125,33 @@ 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 + # HTTP Request Helpers defp get(path) do @@ -255,4 +282,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/test/towerops/billing/stripe_client_test.exs b/test/towerops/billing/stripe_client_test.exs index faf1560c..5141ef74 100644 --- a/test/towerops/billing/stripe_client_test.exs +++ b/test/towerops/billing/stripe_client_test.exs @@ -315,4 +315,49 @@ 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 end From a813f56d1dfac8ef8b2737c27563f62b3ef29f26 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:50:11 -0600 Subject: [PATCH 05/10] feat: add StripeClient.update_subscription_price --- lib/towerops/billing/stripe_client.ex | 19 +++++++ test/towerops/billing/stripe_client_test.exs | 55 ++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lib/towerops/billing/stripe_client.ex b/lib/towerops/billing/stripe_client.ex index a9b357e3..06ed7242 100644 --- a/lib/towerops/billing/stripe_client.ex +++ b/lib/towerops/billing/stripe_client.ex @@ -152,6 +152,25 @@ defmodule Towerops.Billing.StripeClient do 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 diff --git a/test/towerops/billing/stripe_client_test.exs b/test/towerops/billing/stripe_client_test.exs index 5141ef74..c12908ce 100644 --- a/test/towerops/billing/stripe_client_test.exs +++ b/test/towerops/billing/stripe_client_test.exs @@ -360,4 +360,59 @@ defmodule Towerops.Billing.StripeClientTest do 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 From ee918c2eda3e0570a3fb882a62c40fec83f16f32 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:52:28 -0600 Subject: [PATCH 06/10] feat: add best-effort subscription migration to new price --- lib/towerops/billing.ex | 43 +++++++++++++++ test/towerops/billing_test.exs | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/lib/towerops/billing.ex b/lib/towerops/billing.ex index 1b181cad..9aeff595 100644 --- a/lib/towerops/billing.ex +++ b/lib/towerops/billing.ex @@ -227,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/test/towerops/billing_test.exs b/test/towerops/billing_test.exs index 7d5c1507..335dced7 100644 --- a/test/towerops/billing_test.exs +++ b/test/towerops/billing_test.exs @@ -451,4 +451,99 @@ defmodule Towerops.BillingTest do 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 From 4f102f4acc64ed9b16d2073fc381ec6afabc4e6a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:55:12 -0600 Subject: [PATCH 07/10] feat: add Admin.update_global_pricing with Stripe integration --- lib/towerops/admin.ex | 144 ++++++++++++++++++++++++++++++++ lib/towerops/admin/audit_log.ex | 1 + test/towerops/admin_test.exs | 116 +++++++++++++++++++++++++ 3 files changed, 261 insertions(+) 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 From a5e26745aec851d46a5980fb44d719368b38a2b1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:58:29 -0600 Subject: [PATCH 08/10] feat: add global pricing defaults UI to admin orgs page --- lib/towerops_web/live/admin/org_live/index.ex | 143 ++++++++++++++- .../live/admin/org_live/index.html.heex | 171 ++++++++++++++++++ .../live/admin/org_live/index_test.exs | 79 ++++++++ 3 files changed, 389 insertions(+), 4 deletions(-) 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) +

+
+ +
+ +
+
+
Free Devices
+
+ {@default_free_devices} +
+
+
+
+ Price Per Device +
+
+ ${@default_price}/mo +
+
+
+
+
<.table id="organizations" rows={@organizations}> <:col :let={org} label={t("Name")}>{org.name} @@ -130,5 +178,128 @@
<% end %> + + <%!-- Edit Global Defaults Modal --%> + <%= if @editing_global do %> +
+
+
+
+ +
+

+ Edit Global Billing Defaults +

+ + <.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" + > +
+ + + <%= if error = @global_form.errors[:default_free_devices] do %> +

+ {elem(error, 0)} +

+ <% end %> +
+ +
+ + + <%= if error = @global_form.errors[:default_price_per_device] do %> +

+ {elem(error, 0)} +

+ <% end %> +
+ +
+ + +
+ + + <%!-- Confirmation Dialog --%> + <%= if @show_confirm do %> +
+
+

+ Confirm Price Change +

+

+ This will update {@active_sub_count} active subscriptions to the new price. + Changes take effect at the next billing cycle. +

+
+ + +
+
+
+ <% end %> +
+
+
+ <% end %> 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 8bff983e..b3b87724 100644 --- a/test/towerops_web/live/admin/org_live/index_test.exs +++ b/test/towerops_web/live/admin/org_live/index_test.exs @@ -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 From 9a4aa575216c1fd2ad71511b0fd16c33baa7f2d2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:58:58 -0600 Subject: [PATCH 09/10] feat: update marketing pricing from $3 to $2/device/month --- lib/towerops_web/controllers/page_html/home.html.heex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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.

From 6e2bba4e88a4f72f6b84645cf57deba49b03abd6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 14:00:58 -0600 Subject: [PATCH 10/10] docs: update changelogs for dynamic pricing feature --- CHANGELOG.txt | 21 +++++++++++++++++++++ priv/static/changelog.txt | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a95096f8..8e4baff0 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/priv/static/changelog.txt b/priv/static/changelog.txt index c7c35936..1692605d 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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