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
This commit is contained in:
Graham McIntire 2026-03-06 10:16:21 -06:00
parent 1c081623aa
commit 4b4bb2668f
No known key found for this signature in database
3 changed files with 207 additions and 3 deletions

View file

@ -215,10 +215,10 @@ defmodule ToweropsWeb.Layouts do
to: "#org-switcher-button" 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} <span class="truncate">{@current_organization.name}</span>
<.icon name="hero-chevron-down" class="h-3 w-3" /> <.icon name="hero-chevron-down" class="h-3 w-3 shrink-0" />
</button> </button>
<div <div
id="org-switcher-menu" id="org-switcher-menu"

View file

@ -5,10 +5,12 @@ defmodule ToweropsWeb.Org.SettingsLive do
alias Towerops.Accounts.UserNotifier alias Towerops.Accounts.UserNotifier
alias Towerops.Admin.AuditLogger alias Towerops.Admin.AuditLogger
alias Towerops.Agents alias Towerops.Agents
alias Towerops.Billing
alias Towerops.Gaiia.Client, as: GaiiaClient alias Towerops.Gaiia.Client, as: GaiiaClient
alias Towerops.Integrations alias Towerops.Integrations
alias Towerops.Integrations.Integration alias Towerops.Integrations.Integration
alias Towerops.Organizations alias Towerops.Organizations
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Preseem.Client, as: PreseemClient alias Towerops.Preseem.Client, as: PreseemClient
alias Towerops.Sonar.Client, as: SonarClient alias Towerops.Sonar.Client, as: SonarClient
alias Towerops.Splynx.Client, as: SplynxClient alias Towerops.Splynx.Client, as: SplynxClient
@ -88,6 +90,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
changeset = Organizations.change_organization(organization) changeset = Organizations.change_organization(organization)
# Billing information
{current_devices, device_limit} = SubscriptionLimits.device_quota(organization)
{:ok, cost_info} = Billing.estimated_monthly_cost(organization)
{:ok, {:ok,
socket socket
|> assign(:organization, organization) |> assign(:organization, organization)
@ -97,6 +103,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|> assign(:assignment_breakdown, assignment_breakdown) |> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:active_tab, "general") |> 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 # Members tab assigns - loaded lazily
|> assign(:members, []) |> assign(:members, [])
|> assign(:pending_invitations, []) |> assign(:pending_invitations, [])
@ -468,6 +478,55 @@ defmodule ToweropsWeb.Org.SettingsLive do
end end
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 defp do_test_connection("netbox", socket) do
{url, token} = extract_netbox_credentials(socket) {url, token} = extract_netbox_credentials(socket)

View file

@ -91,6 +91,16 @@
{t("Integrations")} {t("Integrations")}
</.link> </.link>
</li> </li>
<li>
<.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")}
</.link>
</li>
</ul> </ul>
</nav> </nav>
</header> </header>
@ -2068,4 +2078,139 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= if @active_tab == "billing" do %>
<div class="space-y-6 px-4 py-6 sm:p-6 lg:p-8">
<div class="border rounded-lg p-6 dark:border-white/10">
<h2 class="text-xl font-semibold mb-4 dark:text-white">{t("Subscription & Billing")}</h2>
<dl class="space-y-3">
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Current Plan")}</dt>
<dd class="text-lg font-semibold capitalize dark:text-white">
{@organization.subscription_plan || "free"}
</dd>
</div>
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Device Usage")}</dt>
<dd class="text-lg dark:text-white">
{@current_devices} {t("devices")}
<%= if @device_limit != :unlimited do %>
<span class="text-gray-500 dark:text-gray-400">
/ {@device_limit} {t("limit")}
</span>
<% end %>
</dd>
</div>
<%= if @organization.subscription_plan == "paid" do %>
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">
{t("Estimated Monthly Cost")}
</dt>
<dd class="text-2xl font-bold text-green-600 dark:text-green-400">
${@estimated_cost.cost_usd}
</dd>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{@estimated_cost.billable} {t("billable devices")} × $1.00
</p>
</div>
<%= if @organization.subscription_status == "active" do %>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded p-3">
<p class="text-sm text-green-800 dark:text-green-300">
✓ {t("Subscription Active")}
</p>
<%= if @organization.subscription_current_period_end do %>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">
{t("Next billing")}: {Calendar.strftime(
@organization.subscription_current_period_end,
"%B %d, %Y"
)}
</p>
<% end %>
</div>
<% end %>
<%= if @organization.subscription_status == "past_due" do %>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded p-3">
<p class="text-sm text-yellow-800 dark:text-yellow-300">
⚠ {t("Payment Past Due")}
</p>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
{t("Please update your payment method to avoid service interruption.")}
</p>
</div>
<% end %>
<% else %>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded p-3">
<p class="text-sm text-blue-800 dark:text-blue-300">
{t("Free Plan - First 10 devices included")}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{t("Upgrade to monitor unlimited devices at $1/device/month")}
</p>
</div>
<% end %>
</dl>
<div class="mt-6 flex gap-3">
<%= if @organization.subscription_plan == "free" do %>
<.button phx-click="upgrade_to_paid" class="btn-primary">
{t("Upgrade to Paid Plan")}
</.button>
<% else %>
<.button phx-click="manage_billing" class="btn-secondary">
{t("Manage Billing")}
</.button>
<% end %>
</div>
</div>
<%= if @organization.subscription_plan == "paid" do %>
<div class="border rounded-lg p-6 dark:border-white/10">
<h3 class="text-lg font-semibold mb-3 dark:text-white">{t("Billing Information")}</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Subscription Status")}</dt>
<dd class="font-medium capitalize dark:text-white">
{@organization.subscription_status}
</dd>
</div>
<%= if @organization.subscription_current_period_start do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period Start")}</dt>
<dd class="font-medium dark:text-white">
{Calendar.strftime(@organization.subscription_current_period_start, "%B %d, %Y")}
</dd>
</div>
<% end %>
<%= if @organization.subscription_current_period_end do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period End")}</dt>
<dd class="font-medium dark:text-white">
{Calendar.strftime(@organization.subscription_current_period_end, "%B %d, %Y")}
</dd>
</div>
<% end %>
<%= if @organization.payment_method_status do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Payment Method")}</dt>
<dd class={[
"font-medium capitalize",
if(@organization.payment_method_status == "valid",
do: "text-green-600 dark:text-green-400",
else: "text-yellow-600 dark:text-yellow-400"
)
]}>
{String.replace(@organization.payment_method_status, "_", " ")}
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
</div>
<% end %>
</Layouts.authenticated> </Layouts.authenticated>