feat: add best-effort subscription migration to new price
This commit is contained in:
parent
a813f56d1d
commit
ee918c2eda
2 changed files with 138 additions and 0 deletions
|
|
@ -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(%{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue