From de075ea8d9db61db7ada0b4f53defa622f54fe3f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 15 Feb 2026 16:51:33 -0600 Subject: [PATCH] Store billing subscriber/MRR data on integrations for dashboard Each billing sync (Gaiia, Splynx, VISP, Sonar) already computes subscriber counts and MRR but only stored them in sync messages. Now persists them on the integrations table so the dashboard can aggregate across all billing providers instead of only querying Gaiia network sites (which were never populated). Also fixes SnmpKit Config environment detection to use Mix.env() as fallback when MIX_ENV env var is not set. --- lib/snmpkit/snmp_lib/config.ex | 1 + lib/towerops/dashboard.ex | 7 +- lib/towerops/gaiia.ex | 14 +++ lib/towerops/gaiia/sync.ex | 8 ++ lib/towerops/integrations.ex | 26 +++++ lib/towerops/integrations/integration.ex | 6 +- lib/towerops/sonar/sync.ex | 1 + lib/towerops/splynx/sync.ex | 1 + lib/towerops/visp/sync.ex | 1 + .../live/org/integrations_live.html.heex | 95 ++++++++++++++----- ...949_add_billing_fields_to_integrations.exs | 10 ++ test/towerops/dashboard_test.exs | 43 ++++----- test/towerops/gaiia/sync_test.exs | 12 +++ test/towerops/integrations_test.exs | 86 +++++++++++++++++ .../towerops_web/live/dashboard_live_test.exs | 15 +-- 15 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 priv/repo/migrations/20260215222949_add_billing_fields_to_integrations.exs 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"