defmodule AccrueAdmin.Live.SubscriptionLive do @moduledoc false use Phoenix.LiveView alias Accrue.{Actor, Auth, Billing, Events} alias Accrue.Billing.Subscription alias Accrue.Repo alias AccrueAdmin.Components.{ AppShell, Breadcrumbs, FlashGroup, JsonViewer, KpiCard, StatusBadge, StepUpAuthModal, Timeline } alias AccrueAdmin.StepUp @destructive_actions ~w(cancel_now comp_subscription) @proration_options [ %{value: "create_prorations", label: "Create prorations"}, %{value: "none", label: "No proration"}, %{value: "always_invoice", label: "Always invoice"} ] @impl true def mount(%{"id" => subscription_id}, session, socket) do admin = Map.get(session, "accrue_admin", %{}) case load_subscription(subscription_id) do nil -> {:ok, redirect(socket, to: admin_path(admin, "/subscriptions"))} subscription -> {:ok, socket |> assign_shell(admin) |> assign(:subscription, subscription) |> assign(:customer, subscription.customer) |> assign(:timeline_events, timeline_events(subscription.id)) |> assign(:proration_options, @proration_options) |> assign(:flashes, []) |> assign(:pending_action, nil)} end end @impl true def handle_event("prepare_action", params, socket) do {:noreply, assign(socket, :pending_action, pending_action(params, socket))} end def handle_event("cancel_pending_action", _params, socket) do {:noreply, assign(socket, :pending_action, nil)} end def handle_event("confirm_action", _params, socket) do pending_action = socket.assigns.pending_action case pending_action do nil -> {:noreply, push_flash(socket, :warning, "Select an action before confirming.")} %{type: type} = action when type in @destructive_actions -> case StepUp.require_fresh( socket, step_up_action(action), &execute_pending_action(&1, action) ) do {:ok, socket} -> {:noreply, socket} {:challenge, socket} -> {:noreply, socket} {:error, reason, socket} -> {:noreply, push_flash(socket, :error, inspect(reason))} end action -> {:noreply, execute_pending_action(socket, action)} end end def handle_event("step_up_submit", params, socket) do case StepUp.verify(socket, params) do {:ok, socket} -> {:noreply, socket} {:error, _reason, socket} -> {:noreply, socket} end end @impl true def render(assigns) do ~H"""
"/subscriptions"}, %{label: @subscription.processor_id || @subscription.id} ]} />

Subscription detail

<%= @subscription.processor_id || @subscription.id %>

<%= @customer.name || @customer.email || @customer.id %> · period ends <%= format_datetime(@subscription.current_period_end) %>

<:meta> <:meta>Use `Accrue.Billing.Subscription` predicates, not raw status branching. <:meta>Ledger events already stored locally

Admin actions

Confirmed billing changes

Choose an optional source event, then stage and confirm an action.

<.source_event_select events={@timeline_events} />
<.source_event_select events={@timeline_events} />
<.source_event_select events={@timeline_events} />
<.source_event_select events={@timeline_events} />
<.source_event_select events={@timeline_events} />
<.source_event_select events={@timeline_events} />

Confirm action

<%= confirm_copy(@pending_action) %>

Ledger timeline

Subscription events

""" end attr(:events, :list, required: true) defp source_event_select(assigns) do ~H""" """ end defp assign_shell(socket, admin) do socket |> assign(:page_title, "Subscription") |> assign(:brand, admin["brand"] || default_brand()) |> assign(:theme, admin["theme"] || "system") |> assign(:csp_nonce, admin["csp_nonce"]) |> assign(:brand_css_path, admin["brand_css_path"]) |> assign(:assets_css_path, admin["assets_css_path"]) |> assign(:assets_js_path, admin["assets_js_path"]) |> assign(:admin_mount_path, admin["mount_path"] || "/billing") |> assign(:current_path, admin_path(admin, "/subscriptions")) end defp load_subscription(subscription_id) do Subscription |> Repo.get(subscription_id) |> case do nil -> nil subscription -> Repo.preload(subscription, [:customer, :subscription_items]) end end defp timeline_events(subscription_id), do: Events.timeline_for("Subscription", subscription_id, limit: 25) defp timeline_items(events) do Enum.map(events, fn event -> %{ title: event.type, at: format_datetime(event.inserted_at), body: event.subject_type <> " " <> event.subject_id, status: event.actor_type, tone: tone(event), meta: "event ##{event.id}" } end) end defp tone(%{actor_type: "admin"}), do: :cobalt defp tone(%{type: type}) when type in ["subscription.paused", "subscription.canceled"], do: :amber defp tone(_event), do: :slate defp predicate_summary(subscription) do [ Accrue.Billing.Subscription.active?(subscription) && "active", Accrue.Billing.Subscription.canceling?(subscription) && "canceling", Accrue.Billing.Subscription.paused?(subscription) && "paused", Accrue.Billing.Subscription.past_due?(subscription) && "past due", Accrue.Billing.Subscription.canceled?(subscription) && "canceled" ] |> Enum.reject(&is_nil/1) |> Enum.reject(&(&1 == false)) |> case do [] -> "no flags" flags -> Enum.join(flags, " · ") end end defp current_price_id(subscription) do case subscription.subscription_items do [%{price_id: price_id} | _rest] -> price_id _ -> nil end end defp pending_action(params, socket) do source_event = selected_source_event(params, socket.assigns.timeline_events) %{ type: Map.fetch!(params, "action_type"), new_price_id: blank_to_nil(params["new_price_id"]), pause_behavior: blank_to_nil(params["pause_behavior"]) || "void", proration: blank_to_nil(params["proration"]) || "create_prorations", source_event_id: source_event && source_event.id, source_webhook_event_id: source_event && source_event.caused_by_webhook_event_id } end defp selected_source_event(%{"source_event_id" => event_id}, events) when event_id not in [nil, ""] do Enum.find(events, fn event -> Integer.to_string(event.id) == event_id end) end defp selected_source_event(_params, _events), do: nil defp step_up_action(action) do %{ type: action.type, subject_type: "Subscription", subject_id: action.source_event_id || "pending", caused_by_event_id: action.source_event_id, caused_by_webhook_event_id: action.source_webhook_event_id } end defp execute_pending_action(socket, action) do subscription = socket.assigns.subscription result = with_admin_context(socket.assigns.current_admin, fn operation_id -> execute_action(subscription, socket.assigns.customer, action, operation_id) end) case result do {:ok, {:comped, new_subscription}} -> socket |> record_admin_audit(action, subscription.id, new_subscription.id) |> refresh_subscription(subscription.id) |> push_flash( :info, "Comp replacement created: #{new_subscription.processor_id || new_subscription.id}" ) {:ok, %Subscription{} = updated_subscription} -> socket |> record_admin_audit(action, updated_subscription.id, updated_subscription.id) |> refresh_subscription(updated_subscription.id) |> push_flash(:info, "Subscription action recorded.") {:ok, :requires_action, payment_intent} -> push_flash(socket, :warning, "Processor requires action: #{inspect(payment_intent)}") {:error, reason} -> push_flash(socket, :error, inspect(reason)) end |> assign(:pending_action, nil) end defp execute_action(subscription, _customer, %{type: "cancel_now"}, operation_id) do Billing.cancel(subscription, operation_id: operation_id) end defp execute_action(subscription, _customer, %{type: "cancel_at_period_end"}, operation_id) do Billing.cancel_at_period_end(subscription, operation_id: operation_id) end defp execute_action( subscription, _customer, %{type: "pause", pause_behavior: behavior}, operation_id ) do Billing.pause(subscription, pause_behavior: behavior, operation_id: operation_id) end defp execute_action(subscription, _customer, %{type: "resume"}, operation_id) do if Accrue.Billing.Subscription.paused?(subscription) do Billing.unpause(subscription, operation_id: operation_id) else Billing.resume(subscription, operation_id: operation_id) end end defp execute_action( _subscription, _customer, %{type: "swap_plan", new_price_id: nil}, _operation_id ) do {:error, :missing_new_price_id} end defp execute_action( subscription, _customer, %{type: "swap_plan", new_price_id: new_price_id, proration: proration}, operation_id ) do Billing.swap_plan(subscription, new_price_id, proration: String.to_existing_atom(proration), operation_id: operation_id ) rescue ArgumentError -> {:error, :invalid_proration} end defp execute_action( _subscription, _customer, %{type: "comp_subscription", new_price_id: nil}, _operation_id ) do {:error, :missing_new_price_id} end defp execute_action( _subscription, customer, %{type: "comp_subscription", new_price_id: new_price_id}, operation_id ) do case Billing.comp_subscription(customer, new_price_id, operation_id: operation_id) do {:ok, %Subscription{} = new_subscription} -> {:ok, {:comped, new_subscription}} other -> other end end defp execute_action(_subscription, _customer, %{type: other}, _operation_id), do: {:error, {:unsupported_action, other}} defp with_admin_context(user, fun) do operation_id = "admin-ui-" <> Ecto.UUID.generate() prior_operation_id = Actor.current_operation_id() try do Actor.with_actor(%{type: :admin, id: Auth.actor_id(user)}, fn -> Actor.put_operation_id(operation_id) fun.(operation_id) end) after Actor.put_operation_id(prior_operation_id) end end defp record_admin_audit(socket, action, subject_id, result_subscription_id) do {:ok, _event} = Events.record(%{ type: "admin.subscription.action.completed", subject_type: "Subscription", subject_id: subject_id, actor_type: "admin", actor_id: Auth.actor_id(socket.assigns.current_admin), caused_by_event_id: action.source_event_id, caused_by_webhook_event_id: action.source_webhook_event_id, data: %{ "action_type" => action.type, "result_subscription_id" => result_subscription_id } }) socket end defp refresh_subscription(socket, subscription_id) do subscription = load_subscription(subscription_id) socket |> assign(:subscription, subscription) |> assign(:customer, subscription.customer) |> assign(:timeline_events, timeline_events(subscription_id)) end defp push_flash(socket, kind, message) do assign(socket, :flashes, [%{kind: kind, message: message} | socket.assigns.flashes]) end defp confirm_copy(action) do source = if action.source_event_id do " Source event ##{action.source_event_id} will be linked." else "" end "#{humanize(action.type)} will execute against the local billing projection.#{source}" end defp subscription_payload(subscription) do %{ "processor_id" => subscription.processor_id, "status" => subscription.status, "cancel_at_period_end" => subscription.cancel_at_period_end, "pause_collection" => subscription.pause_collection, "current_period_start" => subscription.current_period_start, "current_period_end" => subscription.current_period_end, "subscription_items" => Enum.map( subscription.subscription_items || [], &Map.take(&1, [:id, :price_id, :quantity, :processor_id]) ) } end defp blank_to_nil(nil), do: nil defp blank_to_nil(""), do: nil defp blank_to_nil(value), do: value defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize() defp humanize(value) when is_binary(value) do value |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1) end defp humanize(_value), do: "Unknown" defp format_datetime(%DateTime{} = value), do: Calendar.strftime(value, "%b %d, %Y %H:%M UTC") defp format_datetime(_value), do: "Unknown" defp admin_path(admin, suffix), do: (admin["mount_path"] || "/billing") <> suffix defp default_brand do %{app_name: "Billing", logo_url: nil, accent_hex: "#5D79F6", accent_contrast_hex: "#FAFBFC"} end end