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:
parent
1c081623aa
commit
4b4bb2668f
3 changed files with 207 additions and 3 deletions
|
|
@ -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" />
|
||||
<span class="truncate">{@current_organization.name}</span>
|
||||
<.icon name="hero-chevron-down" class="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
<div
|
||||
id="org-switcher-menu"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
alias Towerops.Accounts.UserNotifier
|
||||
alias Towerops.Admin.AuditLogger
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Billing
|
||||
alias Towerops.Gaiia.Client, as: GaiiaClient
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Preseem.Client, as: PreseemClient
|
||||
alias Towerops.Sonar.Client, as: SonarClient
|
||||
alias Towerops.Splynx.Client, as: SplynxClient
|
||||
|
|
@ -88,6 +90,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
changeset = Organizations.change_organization(organization)
|
||||
|
||||
# Billing information
|
||||
{current_devices, device_limit} = SubscriptionLimits.device_quota(organization)
|
||||
{:ok, cost_info} = Billing.estimated_monthly_cost(organization)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,16 @@
|
|||
{t("Integrations")}
|
||||
</.link>
|
||||
</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>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
@ -2068,4 +2078,139 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% 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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue