towerops/lib/towerops/billing.ex
Graham McIntire 62eb7f9949
fix: infer device role during discovery and handle string price values
Device Type Fix:
- Add Topology.maybe_update_device_role() call after discovery completes
- Device types (AP, router, switch, etc.) now inferred immediately after discovery
- Previously devices showed 'unknown' until first polling cycle completed
- Role inference uses SNMP manufacturer, model, and system description

Billing Fix:
- Handle string values from Settings.get_setting() in default_price_per_device()
- Add case clause to convert string '2.00' to Decimal struct
- Fixes CaseClauseError crash on /orgs/:slug/settings page

All discovery tests passing.
2026-03-10 13:25:51 -05:00

293 lines
8.8 KiB
Elixir

defmodule Towerops.Billing do
@moduledoc """
Billing context for subscription and payment management.
Integrates with Stripe for metered billing based on device count.
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("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
string when is_binary(string) -> Decimal.new(string)
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.
Returns URL to redirect user to Stripe-hosted checkout page.
"""
def create_checkout_session(organization, opts \\ []) do
success_url = Keyword.fetch!(opts, :success_url)
cancel_url = Keyword.fetch!(opts, :cancel_url)
with {:ok, customer_id} <- ensure_stripe_customer(organization),
{:ok, checkout_url} <-
StripeClient.create_checkout_session(customer_id,
success_url: success_url,
cancel_url: cancel_url
) do
{:ok, checkout_url}
else
{:error, reason} ->
Logger.error("Failed to create checkout session for org #{organization.id}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Create Stripe Customer Portal session for billing management.
Returns URL to redirect user to Stripe-hosted portal (invoices, payment method, cancel).
"""
def create_portal_session(organization, return_url) do
case organization.stripe_customer_id do
nil ->
{:error, :no_stripe_customer}
customer_id ->
case StripeClient.create_portal_session(customer_id, return_url) do
{:ok, portal_url} ->
{:ok, portal_url}
{:error, reason} ->
Logger.error("Failed to create portal session: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Calculate billable device count (devices over free tier).
## Examples
iex> billable_device_count(organization)
15 # Organization has 25 devices, 10 free = 15 billable
"""
def billable_device_count(organization) do
total = SubscriptionLimits.count_organization_devices(organization.id)
free = effective_free_device_count(organization)
max(0, total - free)
end
@doc """
Returns the effective number of free devices for an organization.
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()
end
@doc """
Returns the effective price per device for an organization.
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()
end
@doc """
Calculate estimated monthly cost based on current device count.
## Examples
iex> estimated_monthly_cost(organization)
{:ok, %{devices: 25, billable: 15, cost_usd: #Decimal<15.00>, free_included: 10, price_per_device: #Decimal<1.00>}}
"""
def estimated_monthly_cost(organization) do
total = SubscriptionLimits.count_organization_devices(organization.id)
free = effective_free_device_count(organization)
price = effective_price_per_device(organization)
billable = max(0, total - free)
cost = Decimal.mult(Decimal.new(billable), price)
{:ok,
%{
devices: total,
billable: billable,
cost_usd: cost,
free_included: free,
price_per_device: price
}}
end
@doc """
Report current device usage to Stripe for metered billing.
Called daily by BillingSyncWorker.
"""
def sync_usage_to_stripe(organization) do
if Organization.has_active_subscription?(organization) do
billable = billable_device_count(organization)
case StripeClient.report_usage(organization.stripe_customer_id, billable) do
:ok ->
update_billing_sync(organization, billable)
Logger.info("Synced usage for org #{organization.slug}: #{billable} billable devices")
:ok
{:error, reason} ->
Logger.error("Failed to sync usage for org #{organization.slug}: #{inspect(reason)}")
{:error, reason}
end
else
{:ok, :no_active_subscription}
end
end
@doc """
Update organization billing status from Stripe subscription object.
Called by webhook processor when subscription events occur.
"""
def update_subscription_from_stripe(organization, stripe_subscription) do
attrs = %{
stripe_subscription_id: stripe_subscription["id"],
subscription_status: stripe_subscription["status"],
subscription_current_period_start: unix_to_datetime(stripe_subscription["current_period_start"]),
subscription_current_period_end: unix_to_datetime(stripe_subscription["current_period_end"]),
subscription_plan: subscription_plan_from_status(stripe_subscription["status"])
}
organization
|> Organization.changeset(attrs)
|> Repo.update()
end
@doc """
Handle payment method status change (valid, failed, requires action).
"""
def update_payment_method_status(organization, status) when status in ["valid", "missing", "requires_action"] do
organization
|> Organization.changeset(%{payment_method_status: status})
|> Repo.update()
end
# Private Helpers
defp ensure_stripe_customer(organization) do
case organization.stripe_customer_id do
nil ->
# Create Stripe customer
case StripeClient.create_customer(organization) do
{:ok, %{"id" => customer_id}} ->
# Store customer ID
organization
|> Organization.changeset(%{stripe_customer_id: customer_id})
|> Repo.update()
{:ok, customer_id}
{:error, reason} ->
{:error, reason}
end
customer_id ->
{:ok, customer_id}
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(%{
last_billing_sync_at: DateTime.utc_now(),
last_synced_device_count: device_count
})
|> Repo.update()
end
defp subscription_plan_from_status("active"), do: "paid"
defp subscription_plan_from_status("trialing"), do: "paid"
defp subscription_plan_from_status("past_due"), do: "paid"
defp subscription_plan_from_status(_), do: "free"
defp unix_to_datetime(nil), do: nil
defp unix_to_datetime(unix_timestamp) do
DateTime.from_unix!(unix_timestamp)
end
end