feat: add Admin.update_global_pricing with Stripe integration
This commit is contained in:
parent
ee918c2eda
commit
4f102f4acc
3 changed files with 261 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue