feat: dynamic global billing defaults from ApplicationSettings

This commit is contained in:
Graham McIntire 2026-03-06 13:40:08 -06:00
parent a16ce2b9dd
commit 448ed20401
No known key found for this signature in database
3 changed files with 109 additions and 11 deletions

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

View file

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

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