towerops/test/towerops/billing_test.exs
Graham McIntire 6e3207f526
fix: resolve all remaining test failures
- Fix ApplicationSetting duplicate key constraint errors in BillingTest and AdminTest
  by deleting existing records before inserting new ones in tests
- Increase timeouts for agent_channel debounce tests from 75ms to 200ms
  to prevent flaky failures on slower systems (50ms debounce + processing time)

All 7633 tests now pass consistently across multiple runs.
2026-03-07 12:13:17 -06:00

555 lines
20 KiB
Elixir

defmodule Towerops.BillingTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.BillingFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Billing
alias Towerops.Billing.StripeClient
alias Towerops.Organizations
alias Towerops.Organizations.Organization
alias Towerops.Settings.ApplicationSetting
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
describe "create_checkout_session/2" do
test "creates Stripe customer and checkout session for new organization", %{
organization: organization
} do
Req.Test.stub(StripeClient, fn conn ->
cond do
# First call: create customer
conn.request_path == "/v1/customers" ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(stripe_customer_object(%{id: "cus_new_123"})))
# Second call: create checkout session
conn.request_path == "/v1/checkout/sessions" ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(stripe_checkout_session_object(%{url: "https://checkout.stripe.com/session"}))
)
end
end)
assert {:ok, url} =
Billing.create_checkout_session(organization,
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel"
)
assert url == "https://checkout.stripe.com/session"
# Verify customer ID was stored
updated_org = Organizations.get_organization!(organization.id)
assert updated_org.stripe_customer_id == "cus_new_123"
end
test "uses existing customer ID when already set", %{organization: organization} do
organization = organization_with_stripe_customer(organization, "cus_existing_123")
Req.Test.stub(StripeClient, fn conn ->
# Should only call checkout session, not customer creation
assert conn.request_path == "/v1/checkout/sessions"
{:ok, body, _} = Plug.Conn.read_body(conn)
params = URI.decode_query(body)
assert params["customer"] == "cus_existing_123"
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(stripe_checkout_session_object())
)
end)
assert {:ok, _url} =
Billing.create_checkout_session(organization,
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel"
)
end
test "returns error when customer creation fails", %{organization: organization} do
Req.Test.stub(StripeClient, fn conn ->
error = %{"error" => %{"message" => "Invalid email"}}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, Jason.encode!(error))
end)
assert {:error, {:bad_request, "Invalid email"}} =
Billing.create_checkout_session(organization,
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel"
)
end
test "returns error when checkout session creation fails", %{organization: organization} do
Req.Test.stub(StripeClient, fn conn ->
cond do
conn.request_path == "/v1/customers" ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(stripe_customer_object()))
conn.request_path == "/v1/checkout/sessions" ->
error = %{"error" => %{"message" => "Price not found"}}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, Jason.encode!(error))
end
end)
assert {:error, {:bad_request, "Price not found"}} =
Billing.create_checkout_session(organization,
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel"
)
end
end
describe "create_portal_session/2" do
test "creates portal session for organization with customer", %{organization: organization} do
organization = organization_with_stripe_customer(organization, "cus_test_123")
Req.Test.stub(StripeClient, fn conn ->
assert conn.request_path == "/v1/billing_portal/sessions"
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(stripe_portal_session_object(%{url: "https://billing.stripe.com/portal"}))
)
end)
assert {:ok, url} = Billing.create_portal_session(organization, "https://example.com/return")
assert url == "https://billing.stripe.com/portal"
end
test "returns error when organization has no customer", %{organization: organization} do
assert {:error, :no_stripe_customer} =
Billing.create_portal_session(organization, "https://example.com/return")
end
test "returns error when portal session creation fails", %{organization: organization} do
organization = organization_with_stripe_customer(organization)
Req.Test.stub(StripeClient, fn conn ->
error = %{"error" => %{"message" => "Customer not found"}}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, Jason.encode!(error))
end)
assert {:error, {:bad_request, "Customer not found"}} =
Billing.create_portal_session(organization, "https://example.com/return")
end
end
describe "billable_device_count/1" do
test "returns 0 for organization with no devices", %{organization: organization} do
assert Billing.billable_device_count(organization) == 0
end
test "returns 0 for organization with 10 or fewer devices", %{organization: organization} do
# Create 10 devices (at free tier limit)
for _ <- 1..10 do
device_fixture(%{organization_id: organization.id})
end
assert Billing.billable_device_count(organization) == 0
end
test "returns count over 10 for organization with more than 10 devices", %{
organization: organization
} do
# Create 25 devices
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
# 25 - 10 free = 15 billable
assert Billing.billable_device_count(organization) == 15
end
test "uses custom free device count when set", %{organization: organization} do
# Set custom free device count to 20
organization
|> Organization.billing_override_changeset(%{custom_free_device_limit: 20})
|> Towerops.Repo.update!()
organization = Towerops.Repo.get!(Organization, organization.id)
# Create 25 devices, 20 free = 5 billable
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
assert Billing.billable_device_count(organization) == 5
end
end
describe "estimated_monthly_cost/1" do
test "returns correct cost calculation", %{organization: organization} do
# Create 25 devices (15 billable at $2.00/device = $30.00)
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok, %{devices: 25, billable: 15, cost_usd: cost}} =
Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(cost, Decimal.new("30.00"))
end
test "returns zero cost for free tier usage", %{organization: organization} do
# Create 5 devices (under free tier)
for _ <- 1..5 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok, %{devices: 5, billable: 0, cost_usd: cost}} =
Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(cost, Decimal.new(0))
end
test "uses custom pricing and free device count", %{organization: organization} do
# Set custom: 5 free devices, $0.50/device
organization
|> Organization.billing_override_changeset(%{
custom_free_device_limit: 5,
custom_price_per_device: "0.50"
})
|> Towerops.Repo.update!()
organization = Towerops.Repo.get!(Organization, organization.id)
# Create 15 devices: 5 free, 10 billable at $0.50 = $5.00
for _ <- 1..15 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok,
%{
devices: 15,
billable: 10,
cost_usd: cost,
free_included: 5,
price_per_device: price
}} = Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(cost, Decimal.new("5.00"))
assert Decimal.equal?(price, Decimal.new("0.50"))
end
test "returns effective values in estimated cost map", %{organization: organization} do
# Default org (no overrides): 10 free, $2.00/device
for _ <- 1..15 do
device_fixture(%{organization_id: organization.id})
end
assert {:ok,
%{
free_included: 10,
price_per_device: price
}} = Billing.estimated_monthly_cost(organization)
assert Decimal.equal?(price, Decimal.new("2.00"))
end
end
describe "sync_usage_to_stripe/1" do
test "reports usage for organization with active subscription", %{organization: organization} do
organization = organization_with_active_subscription(organization)
# Create 25 devices (15 billable)
for _ <- 1..25 do
device_fixture(%{organization_id: organization.id})
end
Req.Test.stub(StripeClient, fn conn ->
assert conn.request_path == "/v1/billing/meter_events"
{:ok, body, _} = Plug.Conn.read_body(conn)
params = URI.decode_query(body)
# Verify reporting 15 billable devices
assert params["payload[value]"] == "15"
assert params["payload[stripe_customer_id]"] == organization.stripe_customer_id
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "mtr_event_123"}))
end)
assert :ok = Billing.sync_usage_to_stripe(organization)
# Verify billing sync timestamp updated
updated_org = Organizations.get_organization!(organization.id)
assert updated_org.last_billing_sync_at
assert updated_org.last_synced_device_count == 15
end
test "skips sync for organization without active subscription", %{organization: organization} do
# Organization on free plan (no active subscription)
assert {:ok, :no_active_subscription} = Billing.sync_usage_to_stripe(organization)
end
test "returns error when usage reporting fails", %{organization: organization} do
organization = organization_with_active_subscription(organization)
Req.Test.stub(StripeClient, fn conn ->
error = %{"error" => %{"message" => "Rate limit exceeded"}}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(429, Jason.encode!(error))
end)
assert {:error, {:rate_limited, _}} = Billing.sync_usage_to_stripe(organization)
end
end
describe "update_subscription_from_stripe/2" do
test "updates organization with subscription data", %{organization: organization} do
organization = organization_with_stripe_customer(organization)
subscription =
stripe_subscription_object(%{
id: "sub_new_123",
status: "active",
period_start: System.system_time(:second),
period_end: System.system_time(:second) + 30 * 86_400
})
assert {:ok, updated_org} = Billing.update_subscription_from_stripe(organization, subscription)
assert updated_org.stripe_subscription_id == "sub_new_123"
assert updated_org.subscription_status == "active"
assert updated_org.subscription_plan == "paid"
assert updated_org.subscription_current_period_start
assert updated_org.subscription_current_period_end
end
test "sets plan to paid for active status", %{organization: organization} do
subscription = stripe_subscription_object(%{status: "active"})
{:ok, org} = Billing.update_subscription_from_stripe(organization, subscription)
assert org.subscription_plan == "paid"
end
test "sets plan to paid for trialing status", %{organization: organization} do
subscription = stripe_subscription_object(%{status: "trialing"})
{:ok, org} = Billing.update_subscription_from_stripe(organization, subscription)
assert org.subscription_plan == "paid"
end
test "sets plan to paid for past_due status", %{organization: organization} do
subscription = stripe_subscription_object(%{status: "past_due"})
{:ok, org} = Billing.update_subscription_from_stripe(organization, subscription)
assert org.subscription_plan == "paid"
end
test "sets plan to free for canceled status", %{organization: organization} do
subscription = stripe_subscription_object(%{status: "canceled"})
{:ok, org} = Billing.update_subscription_from_stripe(organization, subscription)
assert org.subscription_plan == "free"
end
end
describe "update_payment_method_status/2" do
test "updates payment method status to valid", %{organization: organization} do
assert {:ok, updated_org} = Billing.update_payment_method_status(organization, "valid")
assert updated_org.payment_method_status == "valid"
end
test "updates payment method status to missing", %{organization: organization} do
assert {:ok, updated_org} = Billing.update_payment_method_status(organization, "missing")
assert updated_org.payment_method_status == "missing"
end
test "updates payment method status to requires_action", %{organization: organization} do
assert {:ok, updated_org} =
Billing.update_payment_method_status(organization, "requires_action")
assert updated_org.payment_method_status == "requires_action"
end
end
describe "default_free_devices/0" do
test "returns value from ApplicationSettings when set" do
# Clean up any existing setting first
Repo.delete_all(from s in ApplicationSetting, where: s.key == "default_free_devices")
# 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
# Clean up any existing setting first
Repo.delete_all(from s in ApplicationSetting, where: s.key == "default_price_per_device")
{: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
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