diff --git a/lib/snmpkit/snmp_lib/config.ex b/lib/snmpkit/snmp_lib/config.ex index 61579daf..5039b018 100644 --- a/lib/snmpkit/snmp_lib/config.ex +++ b/lib/snmpkit/snmp_lib/config.ex @@ -448,6 +448,7 @@ defmodule SnmpKit.SnmpLib.Config do env = System.get_env("MIX_ENV") -> String.to_atom(env) env = System.get_env("SNMP_LIB_ENV") -> String.to_atom(env) env = Application.get_env(:snmp_lib, :environment) -> env + Code.ensure_loaded?(Mix) -> Mix.env() true -> :dev end end diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex index d910a2bf..85ab7271 100644 --- a/lib/towerops/dashboard.ex +++ b/lib/towerops/dashboard.ex @@ -7,6 +7,7 @@ defmodule Towerops.Dashboard do alias Towerops.Devices alias Towerops.Gaiia alias Towerops.Gaiia.ImpactAnalysis + alias Towerops.Integrations alias Towerops.Preseem alias Towerops.Preseem.Insights alias Towerops.Sites @@ -16,7 +17,7 @@ defmodule Towerops.Dashboard do status_counts = Devices.get_device_status_counts(organization_id) total_devices = status_counts |> Map.values() |> Enum.sum() active_alerts = Alerts.list_organization_active_alerts(organization_id) - subscriber_totals = Gaiia.get_org_subscriber_totals(organization_id) + subscriber_totals = Integrations.get_billing_summary(organization_id) insight_counts = Insights.count_active_by_urgency(organization_id) %{ @@ -83,9 +84,9 @@ defmodule Towerops.Dashboard do end end - @doc "Returns subscriber summary for the org from Gaiia data." + @doc "Returns subscriber summary for the org from billing integrations." def get_org_subscriber_summary(organization_id) do - totals = Gaiia.get_org_subscriber_totals(organization_id) + totals = Integrations.get_billing_summary(organization_id) %{ total: totals.total_subscribers, diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index b8c0c66c..e37da734 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -38,6 +38,20 @@ defmodule Towerops.Gaiia do ) end + def count_active_subscribers(organization_id) do + Account + |> where(organization_id: ^organization_id) + |> where([a], a.subscription_count > 0) + |> Repo.aggregate(:count) + end + + def sum_active_mrr(organization_id) do + BillingSubscription + |> where(organization_id: ^organization_id) + |> where(status: "ACTIVE") + |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0) + end + # --- Network Sites --- def list_network_sites(organization_id) do diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 47eab447..dd2e2fe5 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -37,6 +37,7 @@ defmodule Towerops.Gaiia.Sync do "Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items, #{subs_count} subscriptions" Integrations.update_sync_status(integration, "success", message) + persist_billing_totals(integration) {:ok, %{ @@ -168,6 +169,13 @@ defmodule Towerops.Gaiia.Sync do } end + defp persist_billing_totals(integration) do + org_id = integration.organization_id + subscriber_count = Gaiia.count_active_subscribers(org_id) + total_mrr = Gaiia.sum_active_mrr(org_id) + Integrations.update_billing_totals(integration, subscriber_count, total_mrr) + end + defp client_opts(integration) do case integration.credentials["endpoint"] do nil -> [] diff --git a/lib/towerops/integrations.ex b/lib/towerops/integrations.ex index d4129465..08595cf2 100644 --- a/lib/towerops/integrations.ex +++ b/lib/towerops/integrations.ex @@ -62,6 +62,32 @@ defmodule Towerops.Integrations do end end + @billing_providers ~w(gaiia splynx visp sonar) + + def update_billing_totals(%Integration{} = integration, subscriber_count, total_mrr) do + integration + |> Integration.changeset(%{subscriber_count: subscriber_count, total_mrr: total_mrr}) + |> Repo.update() + end + + def get_billing_summary(organization_id) do + result = + Integration + |> where(organization_id: ^organization_id) + |> where([i], i.provider in @billing_providers) + |> where(enabled: true) + |> select([i], %{ + total_subscribers: coalesce(sum(i.subscriber_count), 0), + total_mrr: coalesce(sum(i.total_mrr), 0) + }) + |> Repo.one() + + %{ + total_subscribers: result.total_subscribers, + total_mrr: result.total_mrr || Decimal.new(0) + } + end + def list_enabled_integrations(provider) do Integration |> where(provider: ^provider, enabled: true) diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 532615af..b211fd62 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -25,6 +25,8 @@ defmodule Towerops.Integrations.Integration do field :last_synced_at, :utc_datetime field :last_sync_status, :string, default: "never" field :last_sync_message, :string + field :subscriber_count, :integer, default: 0 + field :total_mrr, :decimal belongs_to :organization, Towerops.Organizations.Organization @@ -41,7 +43,9 @@ defmodule Towerops.Integrations.Integration do :sync_interval_minutes, :last_synced_at, :last_sync_status, - :last_sync_message + :last_sync_message, + :subscriber_count, + :total_mrr ]) |> validate_required([:organization_id, :provider]) |> validate_inclusion(:provider, @valid_providers) diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex index 9bae166d..56490876 100644 --- a/lib/towerops/sonar/sync.ex +++ b/lib/towerops/sonar/sync.ex @@ -35,6 +35,7 @@ defmodule Towerops.Sonar.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, accounts_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex index 5af37e66..5880db36 100644 --- a/lib/towerops/splynx/sync.ex +++ b/lib/towerops/splynx/sync.ex @@ -33,6 +33,7 @@ defmodule Towerops.Splynx.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, active_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex index 7cbdac49..213431b9 100644 --- a/lib/towerops/visp/sync.ex +++ b/lib/towerops/visp/sync.ex @@ -34,6 +34,7 @@ defmodule Towerops.Visp.Sync do if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, subscribers_count, Decimal.from_float(total_mrr)) {:ok, %{ diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index df4d7c4c..2f76b243 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -32,7 +32,10 @@ <%= if category.exclusive do %> <%!-- Exclusive category (billing): show as picker + single config panel --%> - <% active_billing = Enum.find(category.providers, fn p -> @integrations[p.id] && @integrations[p.id].enabled end) %> + <% active_billing = + Enum.find(category.providers, fn p -> + @integrations[p.id] && @integrations[p.id].enabled + end) %>
<%= if active_billing do %> <%!-- Active billing provider --%> @@ -40,7 +43,10 @@
- <.icon name={active_billing.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={active_billing.icon} + class="h-6 w-6 text-indigo-600 dark:text-indigo-400" + />
@@ -71,10 +77,17 @@ "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - "partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - "failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" - _ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + "success" -> + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" end ]}> {String.capitalize(integration.last_sync_status)} @@ -83,19 +96,26 @@ <%= if integration.last_synced_at do %> - · Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m + · Next sync in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m <% end %> <%= if active_billing.id == "gaiia" do %> <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Entity Mapping → <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Reconciliation → @@ -147,13 +167,17 @@ class={[ "group relative flex flex-col items-center rounded-lg border-2 p-4 text-center transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:hover:border-indigo-700 dark:hover:bg-indigo-900/10", if(@configuring == provider.id, - do: "border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20", + do: + "border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20", else: "border-gray-200 dark:border-white/10" ) ]} >
- <.icon name={provider.icon} class="h-5 w-5 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={provider.icon} + class="h-5 w-5 text-indigo-600 dark:text-indigo-400" + />
{provider.name} @@ -167,7 +191,7 @@ <%!-- Config form for whichever billing provider is being configured --%> <%= if @configuring && find_category_id(@configuring) == "billing" do %> - <% provider = Enum.find(category.providers, & &1.id == @configuring) %> + <% provider = Enum.find(category.providers, &(&1.id == @configuring)) %>

Configure {provider.name} @@ -292,7 +316,9 @@ {t("Webhook Configuration")}

- {t("Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change.")} + {t( + "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." + )}

@@ -347,7 +373,11 @@ type="button" id="regenerate-webhook-secret" phx-click="regenerate_webhook_secret" - data-confirm={t("Are you sure? Any existing Gaiia webhook using the current secret will stop working.")} + data-confirm={ + t( + "Are you sure? Any existing Gaiia webhook using the current secret will stop working." + ) + } class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" > {t("Regenerate Secret")} @@ -383,7 +413,10 @@
- <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> + <.icon + name={provider.icon} + class="h-6 w-6 text-indigo-600 dark:text-indigo-400" + />

@@ -398,7 +431,8 @@ @@ -420,10 +454,17 @@ "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - "partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - "failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" - _ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + "success" -> + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" end ]}> {String.capitalize(integration.last_sync_status)} @@ -432,13 +473,18 @@ <%= if integration.last_synced_at do %> - · Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m + · Next sync in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m <% end %> <%= if provider.id == "preseem" do %> <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"} + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices" + } class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" > Manage Devices → @@ -476,7 +522,10 @@ phx-value-provider={provider.id} class={[ "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2", - if(integration.enabled, do: "bg-indigo-600", else: "bg-gray-200 dark:bg-gray-700") + if(integration.enabled, + do: "bg-indigo-600", + else: "bg-gray-200 dark:bg-gray-700" + ) ]} > 0 + assert updated.subscriber_count == 1 + # 1 active subscription at $79.99 (7999 cents) + assert Decimal.equal?(updated.total_mrr, Decimal.new("79.99")) + end + test "upserts existing records without duplicating", %{org: org, integration: integration} do stub_all_endpoints() diff --git a/test/towerops/integrations_test.exs b/test/towerops/integrations_test.exs index c3869a11..350c09bf 100644 --- a/test/towerops/integrations_test.exs +++ b/test/towerops/integrations_test.exs @@ -121,6 +121,92 @@ defmodule Towerops.IntegrationsTest do end end + describe "update_billing_totals/3" do + test "persists subscriber_count and total_mrr on integration", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + assert {:ok, updated} = + Integrations.update_billing_totals(integration, 42, Decimal.new("3500.00")) + + assert updated.subscriber_count == 42 + assert Decimal.equal?(updated.total_mrr, Decimal.new("3500.00")) + end + + test "overwrites previous billing totals", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "splynx", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(integration, 10, Decimal.new("500.00")) + integration = Repo.reload!(integration) + {:ok, updated} = Integrations.update_billing_totals(integration, 20, Decimal.new("1000.00")) + + assert updated.subscriber_count == 20 + assert Decimal.equal?(updated.total_mrr, Decimal.new("1000.00")) + end + end + + describe "get_billing_summary/1" do + test "sums across multiple billing integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + user2 = user_fixture() + org2 = organization_fixture(user2.id) + + {:ok, i2} = + Integrations.create_integration(org2.id, %{provider: "splynx", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i2, 50, Decimal.new("2500.00")) + + # Only org's integrations + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "excludes disabled integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + {:ok, i2} = + Integrations.create_integration(org.id, %{provider: "splynx", enabled: false}) + + {:ok, _} = Integrations.update_billing_totals(i2, 50, Decimal.new("2500.00")) + + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "excludes non-billing integrations", %{organization: org} do + {:ok, i1} = + Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true}) + + {:ok, _} = Integrations.update_billing_totals(i1, 100, Decimal.new("5000.00")) + + {:ok, _} = + Integrations.create_integration(org.id, %{ + provider: "preseem", + enabled: true + }) + + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 100 + assert Decimal.equal?(summary.total_mrr, Decimal.new("5000.00")) + end + + test "returns zeros when no billing integrations", %{organization: org} do + summary = Integrations.get_billing_summary(org.id) + assert summary.total_subscribers == 0 + assert Decimal.equal?(summary.total_mrr, Decimal.new(0)) + end + end + describe "list_enabled_integrations/1" do test "returns only enabled integrations for a provider", %{organization: org} do {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem", enabled: true}) diff --git a/test/towerops_web/live/dashboard_live_test.exs b/test/towerops_web/live/dashboard_live_test.exs index 255350e4..238c30e6 100644 --- a/test/towerops_web/live/dashboard_live_test.exs +++ b/test/towerops_web/live/dashboard_live_test.exs @@ -120,21 +120,22 @@ defmodule ToweropsWeb.DashboardLiveTest do refute html =~ "MRR" end - test "shows subscriber section when Gaiia data exists", %{ + test "shows subscriber section when billing data exists", %{ conn: conn, organization: organization, site: site } do {:ok, _device} = create_device(organization, site) - {:ok, _} = - Towerops.Gaiia.upsert_network_site(organization.id, %{ - gaiia_id: "ns-dash", - name: "Tower", - account_count: 100, - total_mrr: Decimal.new("5000.00") + {:ok, integration} = + Towerops.Integrations.create_integration(organization.id, %{ + provider: "gaiia", + enabled: true }) + {:ok, _} = + Towerops.Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00")) + {:ok, _view, html} = live(conn, ~p"/dashboard") assert html =~ "Subs"