feat: add best-effort subscription migration to new price

This commit is contained in:
Graham McIntire 2026-03-06 13:52:28 -06:00
parent a813f56d1d
commit ee918c2eda
No known key found for this signature in database
2 changed files with 138 additions and 0 deletions

View file

@ -227,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(%{

View file

@ -451,4 +451,99 @@ defmodule Towerops.BillingTest do
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