- 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.
555 lines
20 KiB
Elixir
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
|