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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue