From ee918c2eda3e0570a3fb882a62c40fec83f16f32 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 13:52:28 -0600 Subject: [PATCH] feat: add best-effort subscription migration to new price --- lib/towerops/billing.ex | 43 +++++++++++++++ test/towerops/billing_test.exs | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/lib/towerops/billing.ex b/lib/towerops/billing.ex index 1b181cad..9aeff595 100644 --- a/lib/towerops/billing.ex +++ b/lib/towerops/billing.ex @@ -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(%{ diff --git a/test/towerops/billing_test.exs b/test/towerops/billing_test.exs index 7d5c1507..335dced7 100644 --- a/test/towerops/billing_test.exs +++ b/test/towerops/billing_test.exs @@ -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