From 4b4bb2668faf63500619f541841e512f8b7ada6a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 6 Mar 2026 10:16:21 -0600 Subject: [PATCH] feat: add billing tab to organization settings Implements Phase 6 of Stripe billing integration: - Add Billing tab to organization settings page - Display subscription status, device usage, and estimated costs - "Upgrade to Paid Plan" button for free tier orgs - "Manage Billing" button to access Stripe Customer Portal - Billing information panel showing subscription details - Support for dark mode and responsive design Also fixes organization name truncation in top navigation: - Long organization names now show with ellipsis instead of being cut off - Added max-w-xs constraint and truncate class to org switcher button --- lib/towerops_web/components/layouts.ex | 6 +- lib/towerops_web/live/org/settings_live.ex | 59 +++++++ .../live/org/settings_live.html.heex | 145 ++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index dfe350a1..7b7f56e6 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -215,10 +215,10 @@ defmodule ToweropsWeb.Layouts do to: "#org-switcher-button" ) } - class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors" + class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors max-w-xs" > - {@current_organization.name} - <.icon name="hero-chevron-down" class="h-3 w-3" /> + {@current_organization.name} + <.icon name="hero-chevron-down" class="h-3 w-3 shrink-0" />
assign(:organization, organization) @@ -97,6 +103,10 @@ defmodule ToweropsWeb.Org.SettingsLive do |> assign(:assignment_breakdown, assignment_breakdown) |> assign(:form, to_form(changeset)) |> assign(:active_tab, "general") + # Billing tab assigns + |> assign(:current_devices, current_devices) + |> assign(:device_limit, device_limit) + |> assign(:estimated_cost, cost_info) # Members tab assigns - loaded lazily |> assign(:members, []) |> assign(:pending_invitations, []) @@ -468,6 +478,55 @@ defmodule ToweropsWeb.Org.SettingsLive do end end + # === Billing events === + + @impl true + def handle_event("upgrade_to_paid", _params, socket) do + org = socket.assigns.organization + + success_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=success") + cancel_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=cancelled") + + case Billing.create_checkout_session(org, + success_url: success_url, + cancel_url: cancel_url + ) do + {:ok, checkout_url} -> + {:noreply, redirect(socket, external: checkout_url)} + + {:error, reason} -> + Logger.error("Failed to create checkout session: #{inspect(reason)}") + + {:noreply, put_flash(socket, :error, t("Failed to start checkout. Please try again later."))} + end + end + + @impl true + def handle_event("manage_billing", _params, socket) do + org = socket.assigns.organization + return_url = url(~p"/orgs/#{org.slug}/settings?tab=billing") + + case Billing.create_portal_session(org, return_url) do + {:ok, portal_url} -> + {:noreply, redirect(socket, external: portal_url)} + + {:error, :no_stripe_customer} -> + {:noreply, + put_flash( + socket, + :error, + t("No billing account found. Please upgrade to a paid plan first.") + )} + + {:error, reason} -> + Logger.error("Failed to create portal session: #{inspect(reason)}") + + {:noreply, put_flash(socket, :error, t("Failed to open billing portal. Please try again later."))} + end + end + + # === Private helpers (integrations) === + defp do_test_connection("netbox", socket) do {url, token} = extract_netbox_credentials(socket) diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index c04c683a..8ff7d5df 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -91,6 +91,16 @@ {t("Integrations")} +
  • + <.link + patch={~p"/orgs/#{@organization.slug}/settings?tab=billing"} + class={ + if @active_tab == "billing", do: "text-indigo-600 dark:text-indigo-400", else: "" + } + > + {t("Billing")} + +
  • @@ -2068,4 +2078,139 @@ <% end %>
    <% end %> + + <%= if @active_tab == "billing" do %> +
    +
    +

    {t("Subscription & Billing")}

    + +
    +
    +
    {t("Current Plan")}
    +
    + {@organization.subscription_plan || "free"} +
    +
    + +
    +
    {t("Device Usage")}
    +
    + {@current_devices} {t("devices")} + <%= if @device_limit != :unlimited do %> + + / {@device_limit} {t("limit")} + + <% end %> +
    +
    + + <%= if @organization.subscription_plan == "paid" do %> +
    +
    + {t("Estimated Monthly Cost")} +
    +
    + ${@estimated_cost.cost_usd} +
    +

    + {@estimated_cost.billable} {t("billable devices")} × $1.00 +

    +
    + + <%= if @organization.subscription_status == "active" do %> +
    +

    + ✓ {t("Subscription Active")} +

    + <%= if @organization.subscription_current_period_end do %> +

    + {t("Next billing")}: {Calendar.strftime( + @organization.subscription_current_period_end, + "%B %d, %Y" + )} +

    + <% end %> +
    + <% end %> + + <%= if @organization.subscription_status == "past_due" do %> +
    +

    + ⚠ {t("Payment Past Due")} +

    +

    + {t("Please update your payment method to avoid service interruption.")} +

    +
    + <% end %> + <% else %> +
    +

    + {t("Free Plan - First 10 devices included")} +

    +

    + {t("Upgrade to monitor unlimited devices at $1/device/month")} +

    +
    + <% end %> +
    + +
    + <%= if @organization.subscription_plan == "free" do %> + <.button phx-click="upgrade_to_paid" class="btn-primary"> + {t("Upgrade to Paid Plan")} + + <% else %> + <.button phx-click="manage_billing" class="btn-secondary"> + {t("Manage Billing")} + + <% end %> +
    +
    + + <%= if @organization.subscription_plan == "paid" do %> +
    +

    {t("Billing Information")}

    +
    +
    +
    {t("Subscription Status")}
    +
    + {@organization.subscription_status} +
    +
    + <%= if @organization.subscription_current_period_start do %> +
    +
    {t("Current Period Start")}
    +
    + {Calendar.strftime(@organization.subscription_current_period_start, "%B %d, %Y")} +
    +
    + <% end %> + <%= if @organization.subscription_current_period_end do %> +
    +
    {t("Current Period End")}
    +
    + {Calendar.strftime(@organization.subscription_current_period_end, "%B %d, %Y")} +
    +
    + <% end %> + <%= if @organization.payment_method_status do %> +
    +
    {t("Payment Method")}
    +
    + {String.replace(@organization.payment_method_status, "_", " ")} +
    +
    + <% end %> +
    +
    + <% end %> +
    + <% end %>