Merge branch 'feature/dynamic-global-pricing'
This commit is contained in:
commit
ac2547e7c9
17 changed files with 1128 additions and 18 deletions
|
|
@ -1,3 +1,24 @@
|
|||
2026-03-06
|
||||
feat: dynamic global pricing configuration with Stripe integration
|
||||
- Add default_free_devices and default_price_per_device to ApplicationSettings
|
||||
- Update default price from $1.00 to $2.00/device/month
|
||||
- Add Billing.default_free_devices/0 and default_price_per_device/0 with fallbacks
|
||||
- Add StripeClient.create_price/1 for metered billing Price creation
|
||||
- Add StripeClient.update_subscription_price/2 for price migrations
|
||||
- Add Billing.migrate_all_subscriptions_to_price/1 with best-effort migration
|
||||
- Add Admin.update_global_pricing/3 with validation and audit logging
|
||||
- Add global defaults UI on /admin/organizations with confirmation dialog
|
||||
- Update marketing pages to reflect $2/device/month pricing
|
||||
Files: priv/repo/migrations/*seed_billing_settings.exs,
|
||||
lib/towerops/settings/application_setting.ex,
|
||||
lib/towerops/billing.ex,
|
||||
lib/towerops/billing/stripe_client.ex,
|
||||
lib/towerops/admin.ex,
|
||||
lib/towerops/admin/audit_log.ex,
|
||||
lib/towerops_web/live/admin/org_live/index.ex,
|
||||
lib/towerops_web/live/admin/org_live/index.html.heex,
|
||||
lib/towerops_web/controllers/page_html/home.html.heex
|
||||
|
||||
2026-03-06
|
||||
feat: per-organization billing override admin UI
|
||||
- Add custom_free_device_limit and custom_price_per_device nullable fields to organizations
|
||||
|
|
|
|||
|
|
@ -94,4 +94,6 @@ config :towerops,
|
|||
# Reduce debounce delay in tests for faster test execution (50ms vs 500ms in prod)
|
||||
agent_channel_debounce_ms: 50,
|
||||
# Reduce device deletion delay for faster tests (10ms vs 500ms in prod)
|
||||
device_deletion_delay_ms: 10
|
||||
device_deletion_delay_ms: 10,
|
||||
# Stripe test configuration
|
||||
stripe_product_id: "prod_test"
|
||||
|
|
|
|||
|
|
@ -269,4 +269,148 @@ defmodule Towerops.Admin do
|
|||
|> preload([:superuser, :target_user])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
## Global Pricing Management
|
||||
|
||||
@doc """
|
||||
Update global pricing settings.
|
||||
|
||||
Creates new Stripe Price if price changed, migrates all active subscriptions,
|
||||
updates ApplicationSettings, and creates audit log.
|
||||
|
||||
Returns {:ok, migration_report} or {:error, reason}.
|
||||
"""
|
||||
def update_global_pricing(attrs, superuser_id, ip_address) do
|
||||
alias Towerops.Billing
|
||||
alias Towerops.Billing.StripeClient
|
||||
alias Towerops.Settings
|
||||
alias Towerops.Settings.ApplicationSetting
|
||||
|
||||
with {:ok, validated} <- validate_pricing_attrs(attrs),
|
||||
old_values = get_current_pricing(),
|
||||
{:ok, new_price_id, migration_report} <- maybe_update_stripe_price(validated, old_values),
|
||||
:ok <- update_pricing_settings(validated, new_price_id),
|
||||
:ok <- create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
|
||||
{:ok, migration_report}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_pricing_attrs(attrs) do
|
||||
free_devices = Map.get(attrs, "default_free_devices")
|
||||
price = Map.get(attrs, "default_price_per_device")
|
||||
|
||||
with {:ok, free} <- validate_free_devices(free_devices),
|
||||
{:ok, price} <- validate_price(price) do
|
||||
{:ok, %{free_devices: free, price: price}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_free_devices(nil), do: {:ok, nil}
|
||||
|
||||
defp validate_free_devices(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, _} when int > 0 and int < 10_000 -> {:ok, int}
|
||||
_ -> {:error, :invalid_free_devices}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_price(nil), do: {:ok, nil}
|
||||
|
||||
defp validate_price(value) when is_binary(value) do
|
||||
case Decimal.parse(value) do
|
||||
{decimal, _} ->
|
||||
if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
|
||||
Decimal.compare(decimal, "999.99") in [:lt, :eq] do
|
||||
{:ok, value}
|
||||
else
|
||||
{:error, :invalid_price}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_price}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_current_pricing do
|
||||
alias Towerops.Settings
|
||||
|
||||
%{
|
||||
free_devices: Settings.get_setting("default_free_devices"),
|
||||
price: Settings.get_setting("default_price_per_device")
|
||||
}
|
||||
end
|
||||
|
||||
defp maybe_update_stripe_price(validated, old_values) do
|
||||
alias Towerops.Billing
|
||||
alias Towerops.Billing.StripeClient
|
||||
|
||||
price_changed = validated.price && validated.price != old_values.price
|
||||
|
||||
if price_changed do
|
||||
case StripeClient.create_price(validated.price) do
|
||||
{:ok, %{"id" => price_id}} ->
|
||||
migration_report = Billing.migrate_all_subscriptions_to_price(price_id)
|
||||
{:ok, price_id, migration_report}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:stripe_error, reason}}
|
||||
end
|
||||
else
|
||||
{:ok, nil, %{total: 0, succeeded: 0, failed: 0, failures: []}}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_pricing_settings(validated, new_price_id) do
|
||||
alias Towerops.Settings
|
||||
alias Towerops.Settings.ApplicationSetting
|
||||
|
||||
if validated.free_devices do
|
||||
Settings.update_setting("default_free_devices", validated.free_devices)
|
||||
end
|
||||
|
||||
if validated.price do
|
||||
Settings.update_setting("default_price_per_device", validated.price)
|
||||
end
|
||||
|
||||
if new_price_id do
|
||||
# Create setting if doesn't exist
|
||||
case Settings.get_setting_record("stripe_price_id") do
|
||||
nil ->
|
||||
%ApplicationSetting{}
|
||||
|> ApplicationSetting.changeset(%{
|
||||
key: "stripe_price_id",
|
||||
value: new_price_id,
|
||||
value_type: "string",
|
||||
description: "Current Stripe Price ID for metered billing"
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
_setting ->
|
||||
Settings.update_setting("stripe_price_id", new_price_id)
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
|
||||
metadata = %{
|
||||
old_free_devices: old_values.free_devices,
|
||||
new_free_devices: validated.free_devices,
|
||||
old_price: old_values.price,
|
||||
new_price: validated.price,
|
||||
migration_total: migration_report.total,
|
||||
migration_succeeded: migration_report.succeeded,
|
||||
migration_failed: migration_report.failed
|
||||
}
|
||||
|
||||
create_audit_log(%{
|
||||
action: "global_pricing_updated",
|
||||
superuser_id: superuser_id,
|
||||
ip_address: ip_address,
|
||||
metadata: metadata
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ defmodule Towerops.Admin.AuditLog do
|
|||
"org_delete",
|
||||
"agent_debug_enable",
|
||||
"agent_debug_disable",
|
||||
"global_pricing_updated",
|
||||
# User data access
|
||||
"user_data_viewed",
|
||||
"user_data_exported",
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
@ -193,6 +227,49 @@ defmodule Towerops.Billing do
|
|||
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(%{
|
||||
|
|
|
|||
|
|
@ -125,6 +125,52 @@ defmodule Towerops.Billing.StripeClient do
|
|||
end
|
||||
end
|
||||
|
||||
# Price Management
|
||||
|
||||
@doc """
|
||||
Create a new Price object for metered billing.
|
||||
|
||||
Returns {:ok, price_object} or {:error, reason}.
|
||||
"""
|
||||
def create_price(unit_amount_decimal) when is_binary(unit_amount_decimal) do
|
||||
params = %{
|
||||
product: stripe_product_id(),
|
||||
currency: "usd",
|
||||
unit_amount_decimal: unit_amount_decimal,
|
||||
recurring: %{
|
||||
interval: "month",
|
||||
usage_type: "metered",
|
||||
aggregate_usage: "max"
|
||||
},
|
||||
billing_scheme: "per_unit",
|
||||
metadata: %{
|
||||
created_by: "towerops_admin_ui",
|
||||
created_at: DateTime.to_iso8601(DateTime.utc_now())
|
||||
}
|
||||
}
|
||||
|
||||
post("/v1/prices", params)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Update subscription to use new price.
|
||||
|
||||
Fetches subscription to get item ID, then updates with proration_behavior: "none"
|
||||
so price change takes effect at next billing cycle.
|
||||
"""
|
||||
def update_subscription_price(subscription_id, new_price_id) do
|
||||
with {:ok, sub} <- get_subscription(subscription_id) do
|
||||
item_id = sub |> get_in(["items", "data"]) |> List.first() |> Map.get("id")
|
||||
|
||||
params = %{
|
||||
items: [%{id: item_id, price: new_price_id}],
|
||||
proration_behavior: "none"
|
||||
}
|
||||
|
||||
post("/v1/subscriptions/#{subscription_id}", params)
|
||||
end
|
||||
end
|
||||
|
||||
# HTTP Request Helpers
|
||||
|
||||
defp get(path) do
|
||||
|
|
@ -255,4 +301,8 @@ defmodule Towerops.Billing.StripeClient do
|
|||
defp stripe_price_id do
|
||||
Application.fetch_env!(:towerops, :stripe_price_id)
|
||||
end
|
||||
|
||||
defp stripe_product_id do
|
||||
Application.fetch_env!(:towerops, :stripe_product_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ defmodule Towerops.Settings.ApplicationSetting do
|
|||
setting
|
||||
|> cast(attrs, [:key, :value, :value_type, :description])
|
||||
|> validate_required([:key, :value_type])
|
||||
|> validate_inclusion(:value_type, ["string", "integer", "uuid", "boolean", "json"])
|
||||
|> validate_inclusion(:value_type, ["string", "integer", "uuid", "boolean", "json", "decimal"])
|
||||
|> unique_constraint(:key)
|
||||
end
|
||||
|
||||
|
|
@ -72,4 +72,11 @@ defmodule Towerops.Settings.ApplicationSetting do
|
|||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_value(%__MODULE__{value: value, value_type: "decimal"}) do
|
||||
case Decimal.parse(value) do
|
||||
{decimal, _remainder} -> decimal
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -327,13 +327,13 @@
|
|||
<div class="mt-16 mx-auto max-w-xl">
|
||||
<div class="rounded-2xl bg-white dark:bg-slate-800 p-8 shadow-lg dark:shadow-black/30 ring-2 ring-blue-600 text-center">
|
||||
<div class="mt-2">
|
||||
<span class="text-5xl font-bold text-slate-900 dark:text-white">$3</span>
|
||||
<span class="text-5xl font-bold text-slate-900 dark:text-white">$2</span>
|
||||
<span class="text-lg text-slate-500 dark:text-slate-400">/device/month</span>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-slate-600 dark:text-slate-300">
|
||||
First 10 devices are
|
||||
<strong class="text-slate-900 dark:text-white">free forever</strong>
|
||||
— all features, no limits. Pay $3/device/month only after that.
|
||||
— all features, no limits. Pay $2/device/month only after that.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 grid grid-cols-2 gap-x-8 gap-y-3 text-sm text-slate-600 dark:text-slate-300 text-left max-w-md mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Billing
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
orgs = Admin.list_all_organizations()
|
||||
default_free_devices = Billing.default_free_devices()
|
||||
default_price = Billing.default_price_per_device()
|
||||
active_sub_count = count_active_subscriptions()
|
||||
|
||||
ip =
|
||||
case get_connect_info(socket, :peer_data) do
|
||||
|
|
@ -24,17 +30,41 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
|> assign(:organizations, orgs)
|
||||
|> assign(:client_ip, ip)
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:default_free_devices, default_free_devices)
|
||||
|> assign(:default_price, default_price)
|
||||
|> assign(:active_sub_count, active_sub_count)
|
||||
|> assign(:editing_global, false)
|
||||
|> assign(:global_form, nil)
|
||||
|> assign(:show_confirm, false)
|
||||
|> assign(:pending_pricing_params, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"edit_global" => "true"} = _params, _url, socket) do
|
||||
form =
|
||||
to_form(%{
|
||||
"default_free_devices" => socket.assigns.default_free_devices,
|
||||
"default_price_per_device" => Decimal.to_string(socket.assigns.default_price)
|
||||
})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_global, true)
|
||||
|> assign(:global_form, form)
|
||||
|> assign(:show_confirm, false)
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
end
|
||||
|
||||
def handle_params(params, _url, socket) do
|
||||
case params["edit"] do
|
||||
nil ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:editing_global, false)}
|
||||
|
||||
org_id ->
|
||||
org = Enum.find(socket.assigns.organizations, &(&1.id == org_id))
|
||||
|
|
@ -45,12 +75,14 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, org)
|
||||
|> assign(:override_form, to_form(changeset))}
|
||||
|> assign(:override_form, to_form(changeset))
|
||||
|> assign(:editing_global, false)}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_org, nil)
|
||||
|> assign(:override_form, nil)}
|
||||
|> assign(:override_form, nil)
|
||||
|> assign(:editing_global, false)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -128,4 +160,107 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
|||
{:noreply, put_flash(socket, :error, t_admin("Failed to delete organization"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit_global_defaults", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/organizations?edit_global=true")}
|
||||
end
|
||||
|
||||
def handle_event("validate_global_pricing", %{"global_pricing" => params}, socket) do
|
||||
errors = validate_global_pricing_params(params)
|
||||
form = to_form(params, errors: errors)
|
||||
|
||||
{:noreply, assign(socket, :global_form, form)}
|
||||
end
|
||||
|
||||
def handle_event("save_global_pricing", %{"global_pricing" => params}, socket) do
|
||||
case validate_global_pricing_params(params) do
|
||||
[] ->
|
||||
# Valid - show confirmation
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_confirm, true)
|
||||
|> assign(:pending_pricing_params, params)}
|
||||
|
||||
errors ->
|
||||
form = to_form(params, errors: errors)
|
||||
{:noreply, assign(socket, :global_form, form)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("confirm_pricing_update", _params, socket) do
|
||||
params = socket.assigns.pending_pricing_params
|
||||
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
|
||||
|
||||
case Admin.update_global_pricing(params, superuser.id, socket.assigns.client_ip) do
|
||||
{:ok, result} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"Pricing updated successfully. #{result.succeeded}/#{result.total} subscriptions migrated."
|
||||
)
|
||||
|> push_patch(to: ~p"/admin/organizations")}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to update pricing: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_global_edit", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/organizations")}
|
||||
end
|
||||
|
||||
defp validate_global_pricing_params(params) do
|
||||
errors = []
|
||||
|
||||
errors =
|
||||
case Map.get(params, "default_free_devices") do
|
||||
nil ->
|
||||
errors
|
||||
|
||||
value ->
|
||||
case Integer.parse(value) do
|
||||
{int, _} when int > 0 and int < 10_000 ->
|
||||
errors
|
||||
|
||||
_ ->
|
||||
[
|
||||
{:default_free_devices, {"must be greater than 0 and less than 10,000", []}}
|
||||
| errors
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
errors =
|
||||
case Map.get(params, "default_price_per_device") do
|
||||
nil ->
|
||||
errors
|
||||
|
||||
value ->
|
||||
case Decimal.parse(value) do
|
||||
{decimal, _} ->
|
||||
if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
|
||||
Decimal.compare(decimal, "999.99") in [:lt, :eq] do
|
||||
errors
|
||||
else
|
||||
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
|
||||
end
|
||||
|
||||
:error ->
|
||||
[{:default_price_per_device, {"must be a valid decimal", []}} | errors]
|
||||
end
|
||||
end
|
||||
|
||||
errors
|
||||
end
|
||||
|
||||
defp count_active_subscriptions do
|
||||
alias Towerops.Repo
|
||||
|
||||
Repo.one(
|
||||
from o in Organization,
|
||||
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
|
||||
select: count(o.id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,54 @@
|
|||
<p class="text-gray-600 dark:text-gray-400">{t_admin("Manage organizations")}</p>
|
||||
</div>
|
||||
|
||||
<!-- Global Billing Defaults Card -->
|
||||
<div
|
||||
class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800"
|
||||
data-test="global-defaults-card"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Global Billing Defaults
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Default pricing for all organizations (can be overridden per-org)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="edit_global_defaults"
|
||||
data-test="edit-global-defaults"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Edit Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">Free Devices</dt>
|
||||
<dd
|
||||
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
|
||||
data-test="default-free-devices"
|
||||
>
|
||||
{@default_free_devices}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
Price Per Device
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-1 text-2xl font-semibold text-slate-900 dark:text-white"
|
||||
data-test="default-price"
|
||||
>
|
||||
${@default_price}/mo
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<.table id="organizations" rows={@organizations}>
|
||||
<:col :let={org} label={t("Name")}>{org.name}</:col>
|
||||
|
|
@ -130,5 +178,128 @@
|
|||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Edit Global Defaults Modal --%>
|
||||
<%= if @editing_global do %>
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
data-test="global-defaults-modal"
|
||||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 bg-slate-500 bg-opacity-75"
|
||||
phx-click="cancel_global_edit"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h3 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Edit Global Billing Defaults
|
||||
</h3>
|
||||
|
||||
<.form
|
||||
for={@global_form}
|
||||
phx-change="validate_global_pricing"
|
||||
phx-submit="save_global_pricing"
|
||||
data-test="global-defaults-form"
|
||||
class="mt-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Free Devices
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="global_pricing[default_free_devices]"
|
||||
value={@global_form.data["default_free_devices"]}
|
||||
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<%= if error = @global_form.errors[:default_free_devices] do %>
|
||||
<p
|
||||
id="global_pricing_default_free_devices-error"
|
||||
class="mt-1 text-sm text-red-600"
|
||||
>
|
||||
{elem(error, 0)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Price Per Device (USD/month)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="global_pricing[default_price_per_device]"
|
||||
value={@global_form.data["default_price_per_device"]}
|
||||
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700"
|
||||
/>
|
||||
<%= if error = @global_form.errors[:default_price_per_device] do %>
|
||||
<p
|
||||
id="global_pricing_default_price_per_device-error"
|
||||
class="mt-1 text-sm text-red-600"
|
||||
>
|
||||
{elem(error, 0)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_global_edit"
|
||||
data-test="cancel-global-edit"
|
||||
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<%!-- Confirmation Dialog --%>
|
||||
<%= if @show_confirm do %>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-slate-900/50"
|
||||
data-test="confirm-pricing-change"
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||
<h4 class="text-lg font-medium text-slate-900 dark:text-white">
|
||||
Confirm Price Change
|
||||
</h4>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
This will update {@active_sub_count} active subscriptions to the new price.
|
||||
Changes take effect at the next billing cycle.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_global_edit"
|
||||
class="rounded-md px-3 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-100 dark:text-white dark:hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm_pricing_update"
|
||||
data-test="confirm-pricing-update"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Confirm Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.admin>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Towerops.Repo.Migrations.SeedBillingSettings do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute """
|
||||
INSERT INTO application_settings (id, key, value, value_type, description, inserted_at, updated_at)
|
||||
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', 'decimal',
|
||||
'Price per device per month (USD) after free tier', NOW(), NOW())
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
"""
|
||||
end
|
||||
|
||||
def down do
|
||||
execute """
|
||||
DELETE FROM application_settings
|
||||
WHERE key IN ('default_free_devices', 'default_price_per_device');
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
2026-03-06 — Pricing Update
|
||||
* Updated pricing to $2/device/month (previously $3 on marketing, $1 in code)
|
||||
* First 10 devices remain free with all features
|
||||
* Improved billing flexibility with per-organization customization
|
||||
|
||||
2026-03-06 — Per-Organization Billing Overrides
|
||||
* Per-organization customization of free device limits and per-device pricing
|
||||
* Organization settings page now reflects custom billing terms when set
|
||||
|
|
|
|||
|
|
@ -780,4 +780,120 @@ defmodule Towerops.AdminTest do
|
|||
assert found_org.device_count == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_global_pricing/3" do
|
||||
test "updates settings, creates Stripe price, migrates subscriptions, and audits" do
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Admin.AuditLog
|
||||
alias Towerops.Billing.StripeClient
|
||||
alias Towerops.Settings
|
||||
alias Towerops.Settings.ApplicationSetting
|
||||
|
||||
superuser = user_fixture(%{superuser: true})
|
||||
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"})
|
||||
|
||||
# Seed current settings
|
||||
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_free_devices", value: "10", value_type: "integer"})
|
||||
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_price_per_device", value: "2.00", value_type: "string"})
|
||||
|
||||
# Mock Stripe
|
||||
Req.Test.stub(StripeClient, fn conn ->
|
||||
cond do
|
||||
conn.method == "POST" and String.contains?(conn.request_path, "/v1/prices") ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "price_new123"}))
|
||||
|
||||
conn.method == "GET" and String.contains?(conn.request_path, "/v1/subscriptions") ->
|
||||
# Return subscription with items
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
"id" => "sub_test",
|
||||
"items" => %{"data" => [%{"id" => "si_test", "price" => %{"id" => "price_old"}}]}
|
||||
})
|
||||
)
|
||||
|
||||
String.contains?(conn.request_path, "/v1/subscriptions") ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(200, Jason.encode!(%{"id" => "sub_test"}))
|
||||
|
||||
true ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(404, Jason.encode!(%{}))
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = %{
|
||||
"default_free_devices" => "15",
|
||||
"default_price_per_device" => "3.00"
|
||||
}
|
||||
|
||||
assert {:ok, result} = Admin.update_global_pricing(attrs, superuser.id, "127.0.0.1")
|
||||
|
||||
# Verify settings updated
|
||||
assert Settings.get_setting("default_free_devices") == 15
|
||||
assert Settings.get_setting("default_price_per_device") == "3.00"
|
||||
assert Settings.get_setting("stripe_price_id") == "price_new123"
|
||||
|
||||
# Verify migration results
|
||||
assert result.total == 2
|
||||
assert result.succeeded == 2
|
||||
|
||||
# Verify audit log
|
||||
log =
|
||||
Repo.one(
|
||||
from al in AuditLog, where: al.action == "global_pricing_updated", order_by: [desc: al.inserted_at], limit: 1
|
||||
)
|
||||
|
||||
assert log.superuser_id == superuser.id
|
||||
assert log.ip_address.address == "127.0.0.1"
|
||||
assert log.metadata["old_free_devices"] == 10
|
||||
assert log.metadata["new_free_devices"] == 15
|
||||
assert log.metadata["old_price"] == "2.00"
|
||||
assert log.metadata["new_price"] == "3.00"
|
||||
assert log.metadata["migration_succeeded"] == 2
|
||||
end
|
||||
|
||||
test "returns ok when price unchanged and no migration needed" do
|
||||
alias Towerops.Settings
|
||||
alias Towerops.Settings.ApplicationSetting
|
||||
|
||||
superuser = user_fixture(%{superuser: true})
|
||||
|
||||
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_free_devices", value: "10", value_type: "integer"})
|
||||
{:ok, _} = Repo.insert(%ApplicationSetting{key: "default_price_per_device", value: "2.00", value_type: "string"})
|
||||
|
||||
# Only change free devices, not price
|
||||
attrs = %{
|
||||
"default_free_devices" => "15",
|
||||
"default_price_per_device" => "2.00"
|
||||
}
|
||||
|
||||
assert {:ok, result} = Admin.update_global_pricing(attrs, superuser.id, "127.0.0.1")
|
||||
|
||||
# No Stripe price created since price unchanged
|
||||
assert is_nil(Settings.get_setting("stripe_price_id"))
|
||||
assert result.total == 0
|
||||
end
|
||||
|
||||
test "validates pricing values" do
|
||||
superuser = user_fixture(%{superuser: true})
|
||||
|
||||
# Invalid free devices (negative)
|
||||
assert {:error, :invalid_free_devices} =
|
||||
Admin.update_global_pricing(%{"default_free_devices" => "-5"}, superuser.id, "127.0.0.1")
|
||||
|
||||
# Invalid price (too high)
|
||||
assert {:error, :invalid_price} =
|
||||
Admin.update_global_pricing(%{"default_price_per_device" => "1000.00"}, superuser.id, "127.0.0.1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -315,4 +315,104 @@ defmodule Towerops.Billing.StripeClientTest do
|
|||
StripeClient.create_customer(organization)
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_price/1" do
|
||||
test "creates metered billing price at specified amount" do
|
||||
Req.Test.stub(StripeClient, fn conn ->
|
||||
assert conn.method == "POST"
|
||||
assert conn.request_path == "/v1/prices"
|
||||
|
||||
# Verify form params
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
params = URI.decode_query(body)
|
||||
|
||||
assert params["unit_amount_decimal"] == "2.50"
|
||||
assert params["currency"] == "usd"
|
||||
assert params["recurring[interval]"] == "month"
|
||||
assert params["recurring[usage_type]"] == "metered"
|
||||
assert params["recurring[aggregate_usage]"] == "max"
|
||||
assert params["billing_scheme"] == "per_unit"
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
"id" => "price_test123",
|
||||
"object" => "price",
|
||||
"unit_amount_decimal" => "2.50"
|
||||
})
|
||||
)
|
||||
end)
|
||||
|
||||
assert {:ok, %{"id" => "price_test123"}} = StripeClient.create_price("2.50")
|
||||
end
|
||||
|
||||
test "returns error on API failure" do
|
||||
Req.Test.stub(StripeClient, fn conn ->
|
||||
error = %{"error" => %{"message" => "Invalid amount"}}
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(400, Jason.encode!(error))
|
||||
end)
|
||||
|
||||
assert {:error, {:bad_request, "Invalid amount"}} = StripeClient.create_price("invalid")
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_subscription_price/2" do
|
||||
test "updates subscription to new price" do
|
||||
# First stub: get subscription
|
||||
Req.Test.stub(StripeClient, fn conn ->
|
||||
if conn.method == "GET" and String.contains?(conn.request_path, "/v1/subscriptions/sub_test") do
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
"id" => "sub_test",
|
||||
"items" => %{
|
||||
"data" => [%{"id" => "si_test123", "price" => %{"id" => "price_old"}}]
|
||||
}
|
||||
})
|
||||
)
|
||||
else
|
||||
# Second stub: update subscription
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
params = URI.decode_query(body)
|
||||
|
||||
assert params["items[0][id]"] == "si_test123"
|
||||
assert params["items[0][price]"] == "price_new123"
|
||||
assert params["proration_behavior"] == "none"
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(
|
||||
200,
|
||||
Jason.encode!(%{
|
||||
"id" => "sub_test",
|
||||
"items" => %{"data" => [%{"price" => %{"id" => "price_new123"}}]}
|
||||
})
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, %{"id" => "sub_test"}} =
|
||||
StripeClient.update_subscription_price("sub_test", "price_new123")
|
||||
end
|
||||
|
||||
test "returns error when subscription not found" do
|
||||
Req.Test.stub(StripeClient, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(
|
||||
404,
|
||||
Jason.encode!(%{"error" => %{"message" => "No such subscription"}})
|
||||
)
|
||||
end)
|
||||
|
||||
assert {:error, _} = StripeClient.update_subscription_price("sub_invalid", "price_new")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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,162 @@ 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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -50,6 +50,27 @@ defmodule Towerops.SettingsTest do
|
|||
{:ok, _setting} = create_setting("test_invalid_json", "not json", "json")
|
||||
assert Settings.get_setting("test_invalid_json") == nil
|
||||
end
|
||||
|
||||
test "parses decimal values correctly" do
|
||||
{:ok, _setting} = create_setting("test_decimal", "123.45", "decimal")
|
||||
|
||||
result = Settings.get_setting("test_decimal")
|
||||
assert result == Decimal.new("123.45")
|
||||
assert %Decimal{} = result
|
||||
end
|
||||
|
||||
test "returns nil for invalid decimal" do
|
||||
{:ok, _setting} = create_setting("test_invalid_decimal", "not_a_number", "decimal")
|
||||
|
||||
assert Settings.get_setting("test_invalid_decimal") == nil
|
||||
end
|
||||
|
||||
test "parses decimal with remainder correctly" do
|
||||
{:ok, _setting} = create_setting("test_decimal_remainder", "123.45abc", "decimal")
|
||||
|
||||
# Decimal.parse stops at invalid chars, returns parsed portion
|
||||
assert Settings.get_setting("test_decimal_remainder") == Decimal.new("123.45")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_setting_record/1" do
|
||||
|
|
|
|||
|
|
@ -165,4 +165,83 @@ defmodule ToweropsWeb.Admin.OrgLive.IndexTest do
|
|||
refute has_element?(view, "#billing-override-form")
|
||||
end
|
||||
end
|
||||
|
||||
describe "global pricing defaults" do
|
||||
test "renders global defaults card", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
|
||||
|
||||
assert has_element?(view, "[data-test='global-defaults-card']")
|
||||
assert has_element?(view, "[data-test='default-free-devices']", "10")
|
||||
assert has_element?(view, "[data-test='default-price']")
|
||||
end
|
||||
|
||||
test "opens edit modal when clicking edit button", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations")
|
||||
|
||||
view
|
||||
|> element("[data-test='edit-global-defaults']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "[data-test='global-defaults-modal']")
|
||||
assert has_element?(view, "input[name='global_pricing[default_free_devices]']")
|
||||
assert has_element?(view, "input[name='global_pricing[default_price_per_device]']")
|
||||
end
|
||||
|
||||
test "validates pricing values", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> form("[data-test='global-defaults-form']", %{
|
||||
global_pricing: %{
|
||||
default_free_devices: "-5",
|
||||
default_price_per_device: "1000.00"
|
||||
}
|
||||
})
|
||||
|> render_change()
|
||||
|
||||
assert has_element?(view, "#global_pricing_default_free_devices-error")
|
||||
assert has_element?(view, "#global_pricing_default_price_per_device-error")
|
||||
end
|
||||
|
||||
test "shows confirmation dialog before saving", %{conn: conn, user: user} do
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
_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"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> form("[data-test='global-defaults-form']", %{
|
||||
global_pricing: %{
|
||||
default_free_devices: "15",
|
||||
default_price_per_device: "3.00"
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
# Should show confirmation dialog
|
||||
assert has_element?(view, "[data-test='confirm-pricing-change']")
|
||||
end
|
||||
|
||||
test "cancels edit without changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/organizations?edit_global=true")
|
||||
|
||||
view
|
||||
|> element("[data-test='cancel-global-edit']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "[data-test='global-defaults-modal']")
|
||||
assert_patched(view, ~p"/admin/organizations")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue