- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior - Fix dynamic_extra_test vendor post-processing tests to seed sensor data - Fix activity_controller_test to seed devices for feed data - Fix session_manager_test to create browser session for test - Fix topology_test link creation for connection test - Fix device_monitor/driver_worker tests for unique job constraints - Fix accounts_test expired_tokens assertion (magic link token is expired) - Fix happy_path_test and show_events_test to seed monitor data - Fix admin user_live_test user.name -> user.email (no name field) - Fix schema_test to seed activity data - Fix mobile_qr_live_test to match actual template text - Fix SnmpKit.MIB doctests and tests for enriched return values - Fix onboarding_live, mobile_controller, mib_test weak assertions - Remove dead code and fix credo warnings
287 lines
8.5 KiB
Elixir
287 lines
8.5 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).
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
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).
|
|
|
|
"""
|
|
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.
|
|
|
|
"""
|
|
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
|
|
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
|