From 448ed204012ddc5907f24e15080e6737661d2409 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:40:08 -0600 Subject: [PATCH] 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